From 5e6dad34e8245547f81b2957cfd80d723729d101 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 1 Oct 2026 23:41:39 +0200 Subject: [PATCH 1/9] chat: add read state action Add the client-dispatchable chat/isReadChanged action and propagate per-chat read state through reducers, schemas, generated clients, and protocol documentation. Advance the protocol capability boundary to 0.10.0 so clients do not dispatch the action to older hosts.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ++++ clients/dotnet/release-metadata.json | 1 + .../Generated/Actions.generated.cs | 18 ++++++++++++++ .../JsonSerializerContext.generated.cs | 1 + .../Generated/Version.generated.cs | 3 ++- .../Generated/ActionMetadata.generated.cs | 3 +++ .../dotnet/src/AgentHostProtocol/Reducers.cs | 4 ++++ .../NativeReducerTests.cs | 11 +++++++++ clients/go/ahp/reducers.go | 3 +++ clients/go/ahptypes/actions.generated.go | 21 ++++++++++++++++ clients/go/ahptypes/version.generated.go | 3 ++- clients/go/release-metadata.json | 1 + clients/kotlin/release-metadata.json | 1 + .../microsoft/agenthostprotocol/Reducers.kt | 4 ++++ .../generated/Actions.generated.kt | 13 ++++++++++ .../generated/Version.generated.kt | 3 ++- clients/rust/crates/ahp-types/src/actions.rs | 19 +++++++++++++++ clients/rust/crates/ahp-types/src/version.rs | 7 +++--- clients/rust/crates/ahp/src/reducers.rs | 4 ++++ clients/rust/release-metadata.json | 1 + .../Generated/Actions.generated.swift | 21 ++++++++++++++++ .../Generated/Version.generated.swift | 3 ++- .../Sources/AgentHostProtocol/Reducers.swift | 6 +++++ .../ReducersTests.swift | 7 ++++++ clients/swift/release-metadata.json | 1 + clients/typescript/release-metadata.json | 1 + .../20261001-chat-read-state-action.json | 4 ++++ docs/guide/actions.md | 2 ++ docs/guide/state-model.md | 2 +- docs/specification/chat-channel.md | 9 +++++++ schema/actions.schema.json | 23 ++++++++++++++++++ schema/commands.schema.json | 20 ++++++++++++++++ schema/errors.schema.json | 20 ++++++++++++++++ scripts/generate-csharp.ts | 1 + scripts/generate-go.ts | 1 + scripts/generate-kotlin.ts | 1 + scripts/generate-rust.ts | 1 + scripts/generate-swift.ts | 1 + types/action-origin.generated.ts | 4 ++++ types/channels-chat/actions.ts | 20 ++++++++++++++++ types/channels-chat/reducer.ts | 3 +++ types/common/actions.ts | 3 +++ ...chat-isreadchanged-marks-chat-as-read.json | 24 +++++++++++++++++++ ...at-isreadchanged-marks-chat-as-unread.json | 24 +++++++++++++++++++ types/version/registry.test.ts | 11 +++++++++ types/version/registry.ts | 4 +++- 46 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 docs/.changes/20261001-chat-read-state-action.json create mode 100644 types/test-cases/reducers/286-chat-isreadchanged-marks-chat-as-read.json create mode 100644 types/test-cases/reducers/287-chat-isreadchanged-marks-chat-as-unread.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c0687a6a7..7cd8dc18f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ changes accumulate. Track in-flight protocol changes via PRs touching `NOTIFICATION_INTRODUCED_IN` maps in [`types/version/registry.ts`](types/version/registry.ts). +## [0.10.0] — Unreleased + +Spec version: `0.10.0` + ## [0.9.0] — 2026-08-28 Spec version: `0.9.0` diff --git a/clients/dotnet/release-metadata.json b/clients/dotnet/release-metadata.json index 128ac239f..c745db9d9 100644 --- a/clients/dotnet/release-metadata.json +++ b/clients/dotnet/release-metadata.json @@ -2,6 +2,7 @@ "client": "dotnet", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index 751701393..efb953df4 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"); @@ -2015,6 +2017,21 @@ public sealed record ChatDraftChangedAction public Message? Draft { get; init; } } +/// The read state of the chat changed. +/// +/// Dispatched by a client to mark a non-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. The default +/// chat's read state is represented by its session and SHOULD use +/// `session/isReadChanged` instead. +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 +2828,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/Version.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs index 1f8c3437d..0418dbbb8 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs @@ -14,10 +14,11 @@ public static class ProtocolVersion /// The current protocol version (SemVer MAJOR.MINOR.PATCH) this /// generated source speaks. /// - public const string Current = "0.9.0"; + public const string Current = "0.10.0"; private static readonly string[] s_supported = { + "0.10.0", "0.9.0", "0.8.0", "0.7.0", 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/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/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..53b703ca1 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" @@ -813,6 +814,19 @@ type ChatDraftChangedAction struct { Draft *Message `json:"draft,omitempty"` } +// The read state of the chat changed. +// +// Dispatched by a client to mark a non-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. The default +// chat's read state is represented by its session and SHOULD use +// `session/isReadChanged` instead. +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 +1815,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 +2124,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/version.generated.go b/clients/go/ahptypes/version.generated.go index 456d539c8..546b8583f 100644 --- a/clients/go/ahptypes/version.generated.go +++ b/clients/go/ahptypes/version.generated.go @@ -6,12 +6,13 @@ package ahptypes // ProtocolVersion is the current protocol version (SemVer // MAJOR.MINOR.PATCH) that this generated source speaks. -const ProtocolVersion = "0.9.0" +const ProtocolVersion = "0.10.0" // supportedProtocolVersions backs [SupportedProtocolVersions] — held // in an unexported slice so callers cannot accidentally mutate the // shared backing array. var supportedProtocolVersions = []string{ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/go/release-metadata.json b/clients/go/release-metadata.json index 4faee915d..3f1b9a894 100644 --- a/clients/go/release-metadata.json +++ b/clients/go/release-metadata.json @@ -2,6 +2,7 @@ "client": "go", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/kotlin/release-metadata.json b/clients/kotlin/release-metadata.json index bdf2a4881..63e469469 100644 --- a/clients/kotlin/release-metadata.json +++ b/clients/kotlin/release-metadata.json @@ -2,6 +2,7 @@ "client": "kotlin", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", 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/Version.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt index 31c2b348c..d4da30550 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt @@ -5,7 +5,7 @@ package com.microsoft.agenthostprotocol.generated /** * Current protocol version (SemVer `MAJOR.MINOR.PATCH`). */ -public const val PROTOCOL_VERSION: String = "0.9.0" +public const val PROTOCOL_VERSION: String = "0.10.0" /** * Every protocol version this library is willing to negotiate, ordered @@ -16,6 +16,7 @@ public const val PROTOCOL_VERSION: String = "0.9.0" * protocol versions if the host doesn't accept the newest one. */ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 776a1ed7e..47e97f524 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, @@ -1476,6 +1479,20 @@ pub struct ChatDraftChangedAction { pub draft: Option, } +/// The read state of the chat changed. +/// +/// Dispatched by a client to mark a non-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. The default +/// chat's read state is represented by its session and SHOULD use +/// `session/isReadChanged` instead. +#[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 +2448,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/version.rs b/clients/rust/crates/ahp-types/src/version.rs index 15cc81d3e..042f44922 100644 --- a/clients/rust/crates/ahp-types/src/version.rs +++ b/clients/rust/crates/ahp-types/src/version.rs @@ -5,7 +5,7 @@ #![allow(missing_docs)] /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -pub const PROTOCOL_VERSION: &str = "0.9.0"; +pub const PROTOCOL_VERSION: &str = "0.10.0"; /// Every protocol version this crate is willing to negotiate, ordered /// most-preferred-first. The first entry equals [`PROTOCOL_VERSION`]. @@ -13,5 +13,6 @@ pub const PROTOCOL_VERSION: &str = "0.9.0"; /// Consumers building `InitializeParams` should pass this slice (or a /// derived `Vec`) so the same client binary can fall back to /// older protocol versions if the host doesn't accept the newest one. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = - &["0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.2", "0.5.1"]; +pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.2", "0.5.1", +]; 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/rust/release-metadata.json b/clients/rust/release-metadata.json index b93f3893f..a756c2ac8 100644 --- a/clients/rust/release-metadata.json +++ b/clients/rust/release-metadata.json @@ -2,6 +2,7 @@ "client": "rust", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", 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/Version.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift index b85278501..35825e387 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift @@ -3,7 +3,7 @@ import Foundation /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -public let PROTOCOL_VERSION: String = "0.9.0" +public let PROTOCOL_VERSION: String = "0.10.0" /// Every protocol version this package is willing to negotiate, /// ordered most-preferred-first. The first entry equals @@ -13,6 +13,7 @@ public let PROTOCOL_VERSION: String = "0.9.0" /// `InitializeParams` so the same client binary can fall back to older /// protocol versions if the host doesn't accept the newest one. public let SUPPORTED_PROTOCOL_VERSIONS: [String] = [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 6f4ef620c..61d708176 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", 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/swift/release-metadata.json b/clients/swift/release-metadata.json index e5693c0d4..485b6ec3d 100644 --- a/clients/swift/release-metadata.json +++ b/clients/swift/release-metadata.json @@ -2,6 +2,7 @@ "client": "swift", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/typescript/release-metadata.json b/clients/typescript/release-metadata.json index 1925c9293..c7d90aa5d 100644 --- a/clients/typescript/release-metadata.json +++ b/clients/typescript/release-metadata.json @@ -2,6 +2,7 @@ "client": "typescript", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", 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..af50a3594 --- /dev/null +++ b/docs/.changes/20261001-chat-read-state-action.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "`chat/isReadChanged` action for marking an individual chat as read or unread." +} diff --git a/docs/guide/actions.md b/docs/guide/actions.md index d592fa1f8..1c567ede7 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 an individual non-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 a non-default chat as read or unread without changing its owning session | | `session/isArchivedChanged` | Archives or unarchives the session | ## Reducers diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index 9800d70c1..6b7a56c42 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -132,7 +132,7 @@ 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. diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 132d431db..1727412d1 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -23,6 +23,15 @@ 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 an individual non-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`. Clients use +`session/isReadChanged` for the default chat because its read state is +represented by the session. + 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/schema/actions.schema.json b/schema/actions.schema.json index 56571ab87..f13e9b812 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -1655,6 +1655,23 @@ "type" ] }, + "ChatIsReadChangedAction": { + "type": "object", + "description": "The read state of the chat changed.\n\nDispatched by a client to mark a non-default chat as read (e.g. after\nviewing it) or unread. This changes only the addressed chat; it does not\nchange the read state of its owning session or sibling chats. The default\nchat's read state is represented by its session and SHOULD use\n`session/isReadChanged` instead.", + "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" }, @@ -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..060f83937 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -9310,6 +9310,23 @@ "type" ] }, + "ChatIsReadChangedAction": { + "type": "object", + "description": "The read state of the chat changed.\n\nDispatched by a client to mark a non-default chat as read (e.g. after\nviewing it) or unread. This changes only the addressed chat; it does not\nchange the read state of its owning session or sibling chats. The default\nchat's read state is represented by its session and SHOULD use\n`session/isReadChanged` instead.", + "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..7acb8011f 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -8617,6 +8617,9 @@ { "$ref": "#/$defs/ChatDraftChangedAction" }, + { + "$ref": "#/$defs/ChatIsReadChangedAction" + }, { "$ref": "#/$defs/ChatIsArchivedChangedAction" }, @@ -10320,6 +10323,23 @@ "type" ] }, + "ChatIsReadChangedAction": { + "type": "object", + "description": "The read state of the chat changed.\n\nDispatched by a client to mark a non-default chat as read (e.g. after\nviewing it) or unread. This changes only the addressed chat; it does not\nchange the read state of its owning session or sibling chats. The default\nchat's read state is represented by its session and SHOULD use\n`session/isReadChanged` instead.", + "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/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..8fd0fc7e7 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -836,6 +836,25 @@ export interface ChatDraftChangedAction { draft?: Message; } +/** + * The read state of the chat changed. + * + * Dispatched by a client to mark a non-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. The default + * chat's read state is represented by its session and SHOULD use + * `session/isReadChanged` instead. + * + * @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 +963,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/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/test-cases/reducers/286-chat-isreadchanged-marks-chat-as-read.json b/types/test-cases/reducers/286-chat-isreadchanged-marks-chat-as-read.json new file mode 100644 index 000000000..af1930045 --- /dev/null +++ b/types/test-cases/reducers/286-chat-isreadchanged-marks-chat-as-read.json @@ -0,0 +1,24 @@ +{ + "description": "chat/isReadChanged marks chat as read", + "reducer": "chat", + "initial": { + "turns": [], + "resource": "ahp-chat://session/peer", + "title": "Peer Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + }, + "actions": [ + { + "type": "chat/isReadChanged", + "isRead": true + } + ], + "expected": { + "turns": [], + "resource": "ahp-chat://session/peer", + "title": "Peer 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..b1218a1bc --- /dev/null +++ b/types/test-cases/reducers/287-chat-isreadchanged-marks-chat-as-unread.json @@ -0,0 +1,24 @@ +{ + "description": "chat/isReadChanged marks chat as unread", + "reducer": "chat", + "initial": { + "turns": [], + "resource": "ahp-chat://session/peer", + "title": "Peer Chat", + "status": 33, + "modifiedAt": "1970-01-01T00:00:01.000Z" + }, + "actions": [ + { + "type": "chat/isReadChanged", + "isRead": false + } + ], + "expected": { + "turns": [], + "resource": "ahp-chat://session/peer", + "title": "Peer Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + } +} diff --git a/types/version/registry.test.ts b/types/version/registry.test.ts index 29862ed4b..0cbce6066 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.10.0', () => { + const action = { + type: ActionType.ChatIsReadChanged, + isRead: true, + } as const; + + assert.equal(ACTION_INTRODUCED_IN[ActionType.ChatIsReadChanged], '0.10.0'); + assert.equal(isActionKnownToVersion(action, '0.9.0'), false); + assert.equal(isActionKnownToVersion(action, '0.10.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..40cc49851 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -15,7 +15,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '0.9.0'; +export const PROTOCOL_VERSION = '0.10.0'; /** * Every protocol version a client built from this source tree is willing @@ -34,6 +34,7 @@ export const PROTOCOL_VERSION = '0.9.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ + '0.10.0', '0.9.0', '0.8.0', '0.7.0', @@ -141,6 +142,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.10.0', [ActionType.ChatIsArchivedChanged]: '0.9.0', [ActionType.ChatInputRequested]: '0.4.0', [ActionType.ChatInputAnswerChanged]: '0.4.0', From ad5384a3ae44ad4a2898c5cea6633f3b093e379c Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 1 Oct 2026 23:47:24 +0200 Subject: [PATCH 2/9] swift: recognize chat read actions as client-dispatchable Keep Swift's enum-based dispatchability check aligned with the generated action-origin metadata and wire-name allowlist.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 61d708176..865b9f9c1 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -1025,7 +1025,7 @@ public func isClientDispatchable(_ action: StateAction) -> Bool { .sessionActiveClientRemoved, .chatPendingMessageSet, .chatPendingMessageRemoved, .chatQueuedMessagesReordered, - .chatIsArchivedChanged, + .chatIsReadChanged, .chatIsArchivedChanged, .chatInputAnswerChanged, .chatInputCompleted, .sessionCustomizationToggled, .sessionMcpServerStartRequested, .sessionMcpServerStopRequested, From af155c67cdf0c15bcdc45721647227b7f2435f1b Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 2 Oct 2026 11:38:00 +0200 Subject: [PATCH 3/9] chat: allow read state changes for default chats Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generated/Actions.generated.cs | 10 ++--- clients/go/ahptypes/actions.generated.go | 10 ++--- clients/rust/crates/ahp-types/src/actions.rs | 10 ++--- .../20261001-chat-read-state-action.json | 2 +- docs/guide/actions.md | 4 +- docs/guide/state-model.md | 5 +++ docs/specification/chat-channel.md | 9 ++-- schema/actions.schema.json | 2 +- schema/commands.schema.json | 2 +- schema/errors.schema.json | 2 +- types/channels-chat/actions.ts | 10 ++--- types/reducers.test.ts | 44 +++++++++++++++++++ ...adchanged-marks-default-chat-as-read.json} | 10 ++--- ...at-isreadchanged-marks-chat-as-unread.json | 10 ++--- 14 files changed, 90 insertions(+), 40 deletions(-) rename types/test-cases/reducers/{286-chat-isreadchanged-marks-chat-as-read.json => 286-chat-isreadchanged-marks-default-chat-as-read.json} (57%) diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index efb953df4..a85ff4fdb 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -2019,11 +2019,11 @@ public sealed record ChatDraftChangedAction /// The read state of the chat changed. /// -/// Dispatched by a client to mark a non-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. The default -/// chat's read state is represented by its session and SHOULD use -/// `session/isReadChanged` instead. +/// 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. public sealed record ChatIsReadChangedAction { public ActionType Type { get; init; } = ActionType.ChatIsReadChanged; diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index 53b703ca1..6f21997c2 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -816,11 +816,11 @@ type ChatDraftChangedAction struct { // The read state of the chat changed. // -// Dispatched by a client to mark a non-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. The default -// chat's read state is represented by its session and SHOULD use -// `session/isReadChanged` instead. +// 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. type ChatIsReadChangedAction struct { Type ActionType `json:"type"` // Whether the chat has been read diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 47e97f524..149986a54 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -1481,11 +1481,11 @@ pub struct ChatDraftChangedAction { /// The read state of the chat changed. /// -/// Dispatched by a client to mark a non-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. The default -/// chat's read state is represented by its session and SHOULD use -/// `session/isReadChanged` instead. +/// 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. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatIsReadChangedAction { diff --git a/docs/.changes/20261001-chat-read-state-action.json b/docs/.changes/20261001-chat-read-state-action.json index af50a3594..26b47bfdf 100644 --- a/docs/.changes/20261001-chat-read-state-action.json +++ b/docs/.changes/20261001-chat-read-state-action.json @@ -1,4 +1,4 @@ { "type": "added", - "message": "`chat/isReadChanged` action for marking an individual chat as read or unread." + "message": "`chat/isReadChanged` action for independently marking any known chat, including the default chat, as read or unread." } diff --git a/docs/guide/actions.md b/docs/guide/actions.md index 1c567ede7..87242af79 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -105,7 +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 an individual non-default chat 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 | @@ -215,7 +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 a non-default chat as read or unread without changing its owning session | +| `chat/isReadChanged` | Marks any known chat, including the default chat, as read or unread without changing its owning session or sibling chats | | `session/isArchivedChanged` | Archives or unarchives the session | ## Reducers diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index 6b7a56c42..4e8e53859 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -137,6 +137,11 @@ The `status` bitset encodes both the session's activity state and metadata flags 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 1727412d1..457ed9228 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -23,14 +23,15 @@ 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 an individual non-default chat as read or unread by dispatching +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`. Clients use -`session/isReadChanged` for the default chat because its read state is -represented by the session. +`ChatSummary.status` synchronized through `session/chatUpdated`. +`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 diff --git a/schema/actions.schema.json b/schema/actions.schema.json index f13e9b812..832b5a262 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -1657,7 +1657,7 @@ }, "ChatIsReadChangedAction": { "type": "object", - "description": "The read state of the chat changed.\n\nDispatched by a client to mark a non-default chat as read (e.g. after\nviewing it) or unread. This changes only the addressed chat; it does not\nchange the read state of its owning session or sibling chats. The default\nchat's read state is represented by its session and SHOULD use\n`session/isReadChanged` instead.", + "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.", "properties": { "type": { "const": "chat/isReadChanged" diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 060f83937..6753e4de6 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -9312,7 +9312,7 @@ }, "ChatIsReadChangedAction": { "type": "object", - "description": "The read state of the chat changed.\n\nDispatched by a client to mark a non-default chat as read (e.g. after\nviewing it) or unread. This changes only the addressed chat; it does not\nchange the read state of its owning session or sibling chats. The default\nchat's read state is represented by its session and SHOULD use\n`session/isReadChanged` instead.", + "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.", "properties": { "type": { "const": "chat/isReadChanged" diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 7acb8011f..f74dadcb7 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -10325,7 +10325,7 @@ }, "ChatIsReadChangedAction": { "type": "object", - "description": "The read state of the chat changed.\n\nDispatched by a client to mark a non-default chat as read (e.g. after\nviewing it) or unread. This changes only the addressed chat; it does not\nchange the read state of its owning session or sibling chats. The default\nchat's read state is represented by its session and SHOULD use\n`session/isReadChanged` instead.", + "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.", "properties": { "type": { "const": "chat/isReadChanged" diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index 8fd0fc7e7..7f58a62b0 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -839,11 +839,11 @@ export interface ChatDraftChangedAction { /** * The read state of the chat changed. * - * Dispatched by a client to mark a non-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. The default - * chat's read state is represented by its session and SHOULD use - * `session/isReadChanged` instead. + * 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. * * @category Chat Actions * @version 1 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-chat-as-read.json b/types/test-cases/reducers/286-chat-isreadchanged-marks-default-chat-as-read.json similarity index 57% rename from types/test-cases/reducers/286-chat-isreadchanged-marks-chat-as-read.json rename to types/test-cases/reducers/286-chat-isreadchanged-marks-default-chat-as-read.json index af1930045..e573f40c5 100644 --- a/types/test-cases/reducers/286-chat-isreadchanged-marks-chat-as-read.json +++ b/types/test-cases/reducers/286-chat-isreadchanged-marks-default-chat-as-read.json @@ -1,10 +1,10 @@ { - "description": "chat/isReadChanged marks chat as read", + "description": "chat/isReadChanged marks the addressed default chat as read", "reducer": "chat", "initial": { "turns": [], - "resource": "ahp-chat://session/peer", - "title": "Peer Chat", + "resource": "ahp-chat://session/default", + "title": "Default Chat", "status": 1, "modifiedAt": "1970-01-01T00:00:01.000Z" }, @@ -16,8 +16,8 @@ ], "expected": { "turns": [], - "resource": "ahp-chat://session/peer", - "title": "Peer Chat", + "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 index b1218a1bc..9bc9ff788 100644 --- 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 @@ -1,10 +1,10 @@ { - "description": "chat/isReadChanged marks chat as unread", + "description": "chat/isReadChanged marks the addressed sibling chat as unread", "reducer": "chat", "initial": { "turns": [], - "resource": "ahp-chat://session/peer", - "title": "Peer Chat", + "resource": "ahp-chat://session/sibling", + "title": "Sibling Chat", "status": 33, "modifiedAt": "1970-01-01T00:00:01.000Z" }, @@ -16,8 +16,8 @@ ], "expected": { "turns": [], - "resource": "ahp-chat://session/peer", - "title": "Peer Chat", + "resource": "ahp-chat://session/sibling", + "title": "Sibling Chat", "status": 1, "modifiedAt": "1970-01-01T00:00:01.000Z" } From dedb7f45df8e0646d58404ed37ae52d35299d506 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 2 Oct 2026 11:58:05 +0200 Subject: [PATCH 4/9] chat: expose read state in session summaries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generated/Actions.generated.cs | 9 ++- .../Generated/Notifications.generated.cs | 3 +- .../Generated/State.generated.cs | 9 +++ .../Hosts/MultiHostClient.cs | 2 + .../FixRegressionTests.cs | 46 +++++++++++++++ clients/go/ahptypes/actions.generated.go | 7 ++- .../go/ahptypes/notifications.generated.go | 1 + clients/go/ahptypes/state.generated.go | 7 +++ .../generated/Notifications.generated.kt | 1 + .../generated/State.generated.kt | 9 +++ clients/rust/crates/ahp-types/src/actions.rs | 7 ++- .../crates/ahp-types/src/notifications.rs | 1 + clients/rust/crates/ahp-types/src/state.rs | 8 +++ clients/rust/crates/ahp/src/hosts/runtime.rs | 44 ++++++++++++++ .../swift/AHPApp/AHPApp/Store/AppStore.swift | 1 + .../Generated/Notifications.generated.swift | 1 + .../Generated/State.generated.swift | 9 +++ .../Hosts/HostRuntime.swift | 1 + .../typescript/src/client/hosts/runtime.ts | 1 + clients/typescript/test/hosts.test.ts | 49 ++++++++++++++++ .../20261001-chat-read-state-action.json | 2 +- docs/guide/actions.md | 2 +- docs/guide/state-model.md | 8 +++ docs/specification/chat-channel.md | 4 +- docs/specification/session-channel.md | 7 +++ schema/actions.schema.json | 8 ++- schema/commands.schema.json | 8 ++- schema/errors.schema.json | 8 ++- schema/notifications.schema.json | 6 +- schema/state.schema.json | 4 ++ types/channels-chat/actions.ts | 4 +- types/channels-root/notifications.ts | 1 + types/channels-session/actions.ts | 3 + types/channels-session/state.ts | 9 +++ ...-chatupdated-mirrors-chat-read-status.json | 57 ++++++++++++++++++ ...on-chat-summary-read-state-round-trip.json | 58 +++++++++++++++++++ 36 files changed, 389 insertions(+), 16 deletions(-) create mode 100644 types/test-cases/reducers/288-session-chatupdated-mirrors-chat-read-status.json create mode 100644 types/test-cases/round-trips/053-session-chat-summary-read-state-round-trip.json diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index a85ff4fdb..532568148 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -1161,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 `IsRead` bit, the host MUST project that +/// exact value into the matching `SessionChatSummary.isRead` field and publish +/// the updated compact chat catalog through `root/sessionSummaryChanged`. public sealed record SessionChatUpdatedAction { public ActionType Type { get; init; } = ActionType.SessionChatUpdated; @@ -2023,7 +2026,9 @@ public sealed record ChatDraftChangedAction /// 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. +/// the owning session's independent read state. After accepting this action, +/// the host also synchronizes the addressed chat's `ChatSummary.status` and +/// `SessionChatSummary.isRead` projections. public sealed record ChatIsReadChangedAction { public ActionType Type { get; init; } = ActionType.ChatIsReadChanged; 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..ee3b97315 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -3168,6 +3168,15 @@ public sealed record SessionChatSummary [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ChatInteractivity? Interactivity { get; init; } + /// Exact read state for this chat. + /// + /// Generic clients use this to present read or unread chats in session lists + /// without subscribing to the session or chat channel. `true` means read and + /// `false` means unread. Absence means unknown for backward compatibility and + /// MUST NOT be interpreted as read. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? IsRead { get; init; } + /// Whether this chat has been archived independently of its owning session /// (see `chat/isArchivedChanged`). /// 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/tests/AgentHostProtocol.Tests/FixRegressionTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs index de82b2d5e..21438b990 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_ReplacesCompactReadProjection() + { + 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", + IsRead = false, + }, + ], + }); + + entry.ApplySummaryChange("ahp-session:/s1", new PartialSessionSummary + { + Chats = + [ + new SessionChatSummary + { + Resource = "ahp-chat:/default", + Title = "Default", + IsRead = true, + }, + ], + }); + + var summary = entry.Snapshot().SessionSummaries.Single(s => s.Resource == "ahp-session:/s1"); + Assert.True(Assert.Single(summary.Chats!).IsRead); + } + // ── 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/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index 6f21997c2..a6ce87f41 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -212,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 `IsRead` bit, the host MUST project that +// exact value into the matching `SessionChatSummary.isRead` 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. @@ -820,7 +823,9 @@ type ChatDraftChangedAction struct { // 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. +// the owning session's independent read state. After accepting this action, +// the host also synchronizes the addressed chat's `ChatSummary.status` and +// `SessionChatSummary.isRead` projections. type ChatIsReadChangedAction struct { Type ActionType `json:"type"` // Whether the chat has been read 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..e4a9d2f7c 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -1229,6 +1229,13 @@ type SessionChatSummary struct { // read-only chats. Absence defaults to {@link ChatInteractivity.Full} for // backward compatibility. Interactivity *ChatInteractivity `json:"interactivity,omitempty"` + // Exact read state for this chat. + // + // Generic clients use this to present read or unread chats in session lists + // without subscribing to the session or chat channel. `true` means read and + // `false` means unread. Absence means unknown for backward compatibility and + // MUST NOT be interpreted as read. + IsRead *bool `json:"isRead,omitempty"` // Whether this chat has been archived independently of its owning session // (see `chat/isArchivedChanged`). // 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..7cc06ae59 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 @@ -2289,6 +2289,15 @@ data class SessionChatSummary( * backward compatibility. */ val interactivity: ChatInteractivity? = null, + /** + * Exact read state for this chat. + * + * Generic clients use this to present read or unread chats in session lists + * without subscribing to the session or chat channel. `true` means read and + * `false` means unread. Absence means unknown for backward compatibility and + * MUST NOT be interpreted as read. + */ + val isRead: Boolean? = null, /** * Whether this chat has been archived independently of its owning session * (see `chat/isArchivedChanged`). diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 149986a54..0638c2465 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -538,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 `IsRead` bit, the host MUST project that +/// exact value into the matching `SessionChatSummary.isRead` field and publish +/// the updated compact chat catalog through `root/sessionSummaryChanged`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionChatUpdatedAction { @@ -1485,7 +1488,9 @@ pub struct ChatDraftChangedAction { /// 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. +/// the owning session's independent read state. After accepting this action, +/// the host also synchronizes the addressed chat's `ChatSummary.status` and +/// `SessionChatSummary.isRead` projections. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatIsReadChangedAction { 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..c7131b9b5 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -2571,6 +2571,14 @@ pub struct SessionChatSummary { /// backward compatibility. #[serde(default, skip_serializing_if = "Option::is_none")] pub interactivity: Option, + /// Exact read state for this chat. + /// + /// Generic clients use this to present read or unread chats in session lists + /// without subscribing to the session or chat channel. `true` means read and + /// `false` means unread. Absence means unknown for backward compatibility and + /// MUST NOT be interpreted as read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_read: Option, /// Whether this chat has been archived independently of its owning session /// (see `chat/isArchivedChanged`). /// diff --git a/clients/rust/crates/ahp/src/hosts/runtime.rs b/clients/rust/crates/ahp/src/hosts/runtime.rs index 6bfa33d7d..504213294 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,44 @@ 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_read_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", + "isRead": false + }] + })) + .expect("valid session summary"); + let changes: PartialSessionSummary = serde_json::from_value(json!({ + "chats": [{ + "resource": "ahp-chat:/default", + "title": "Default", + "isRead": true + }] + })) + .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.is_read), + Some(true) + ); + } +} 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/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..9894d683a 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -2439,6 +2439,13 @@ public struct SessionChatSummary: Codable, Sendable { /// read-only chats. Absence defaults to {@link ChatInteractivity.Full} for /// backward compatibility. public var interactivity: ChatInteractivity? + /// Exact read state for this chat. + /// + /// Generic clients use this to present read or unread chats in session lists + /// without subscribing to the session or chat channel. `true` means read and + /// `false` means unread. Absence means unknown for backward compatibility and + /// MUST NOT be interpreted as read. + public var isRead: Bool? /// Whether this chat has been archived independently of its owning session /// (see `chat/isArchivedChanged`). /// @@ -2458,6 +2465,7 @@ public struct SessionChatSummary: Codable, Sendable { title: String, origin: ChatOrigin? = nil, interactivity: ChatInteractivity? = nil, + isRead: Bool? = nil, archived: Bool? = nil, changes: ChangesSummary? = nil ) { @@ -2465,6 +2473,7 @@ public struct SessionChatSummary: Codable, Sendable { self.title = title self.origin = origin self.interactivity = interactivity + self.isRead = isRead self.archived = archived self.changes = changes } 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/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..f46fadb6b 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 read projection', async () => { + const initial = makeSummary('copilot:/s1', 'Session', 1_000); + initial.chats = [ + { resource: 'ahp-chat:/default', title: 'Default', isRead: false }, + ]; + 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', isRead: true }, + ], + }, + }, + }; + 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]?.isRead === true + ); + + assert.deepEqual(multi.aggregatedSessions()[0]?.summary.chats, [ + { resource: 'ahp-chat:/default', title: 'Default', isRead: true }, + ]); + } finally { + await multi.shutdown(); + } +}); + test('aggregatedAgents tags every agent with its host', async () => { const multi = new MultiHostClient(); try { diff --git a/docs/.changes/20261001-chat-read-state-action.json b/docs/.changes/20261001-chat-read-state-action.json index 26b47bfdf..5a969cb88 100644 --- a/docs/.changes/20261001-chat-read-state-action.json +++ b/docs/.changes/20261001-chat-read-state-action.json @@ -1,4 +1,4 @@ { "type": "added", - "message": "`chat/isReadChanged` action for independently marking any known chat, including the default chat, as read or unread." + "message": "`chat/isReadChanged` action and `SessionChatSummary.isRead` projection for independently tracking the exact read state of any known chat, including the default chat." } diff --git a/docs/guide/actions.md b/docs/guide/actions.md index 87242af79..09d4bdcb4 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -215,7 +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 | +| `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.isRead` projection | | `session/isArchivedChanged` | Archives or unarchives the session | ## Reducers diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index 4e8e53859..6f0c54976 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 exact per-chat read state when known } ProjectInfo { @@ -120,6 +121,13 @@ ProjectInfo { } ``` +`SessionChatSummary.isRead` is optional for backward compatibility. `true` +means the chat is read, `false` means it is unread, and absence means unknown; +clients must not interpret an absent value as read. Hosts update this compact +projection alongside `ChatSummary.status` when `chat/isReadChanged` is +accepted, so session lists can restore exact per-chat read state without +subscribing to every session or chat. + 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 diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 457ed9228..a40e8be3c 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -29,7 +29,9 @@ read or unread by dispatching 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`. +`ChatSummary.status` synchronized through `session/chatUpdated` and the +matching compact `SessionChatSummary.isRead` projection synchronized through +`root/sessionSummaryChanged`. `session/isReadChanged` independently changes the owning session's read state; neither action implies the other. diff --git a/docs/specification/session-channel.md b/docs/specification/session-channel.md index f35e4693f..36a960838 100644 --- a/docs/specification/session-channel.md +++ b/docs/specification/session-channel.md @@ -65,6 +65,13 @@ 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 `SessionStatus.IsRead` bit changes, the producer MUST project its +exact value into the matching +[`SessionChatSummary.isRead`](/reference/session#sessionchatsummary) field and +publish the complete compact `SessionSummary.chats` catalog through +`root/sessionSummaryChanged`. `true` means read and `false` means unread. +Absence means the host did not provide the projection and clients MUST treat +the state as unknown, not as read. 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 832b5a262..4dc7e1cb9 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 `IsRead` bit, the host MUST project that\nexact value into the matching `SessionChatSummary.isRead` field and publish\nthe updated compact chat catalog through `root/sessionSummaryChanged`.", "properties": { "type": { "const": "session/chatUpdated" @@ -1657,7 +1657,7 @@ }, "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.", + "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.isRead` projections.", "properties": { "type": { "const": "chat/isReadChanged" @@ -3765,6 +3765,10 @@ "$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." }, + "isRead": { + "type": "boolean", + "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." + }, "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." diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 6753e4de6..a6c6d0295 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -2980,6 +2980,10 @@ "$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." }, + "isRead": { + "type": "boolean", + "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." + }, "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." @@ -7847,7 +7851,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 `IsRead` bit, the host MUST project that\nexact value into the matching `SessionChatSummary.isRead` field and publish\nthe updated compact chat catalog through `root/sessionSummaryChanged`.", "properties": { "type": { "const": "session/chatUpdated" @@ -9312,7 +9316,7 @@ }, "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.", + "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.isRead` projections.", "properties": { "type": { "const": "chat/isReadChanged" diff --git a/schema/errors.schema.json b/schema/errors.schema.json index f74dadcb7..1af3e7b5e 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -1243,6 +1243,10 @@ "$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." }, + "isRead": { + "type": "boolean", + "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." + }, "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." @@ -9011,7 +9015,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 `IsRead` bit, the host MUST project that\nexact value into the matching `SessionChatSummary.isRead` field and publish\nthe updated compact chat catalog through `root/sessionSummaryChanged`.", "properties": { "type": { "const": "session/chatUpdated" @@ -10325,7 +10329,7 @@ }, "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.", + "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.isRead` projections.", "properties": { "type": { "const": "chat/isReadChanged" diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index c43b4d93d..91ebfc422 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,6 +1421,10 @@ "$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." }, + "isRead": { + "type": "boolean", + "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." + }, "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." diff --git a/schema/state.schema.json b/schema/state.schema.json index 335969bc1..53c4931ab 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -1154,6 +1154,10 @@ "$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." }, + "isRead": { + "type": "boolean", + "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." + }, "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." diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index 7f58a62b0..5f5d71058 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -843,7 +843,9 @@ export interface ChatDraftChangedAction { * 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. + * the owning session's independent read state. After accepting this action, + * the host also synchronizes the addressed chat's `ChatSummary.status` and + * `SessionChatSummary.isRead` projections. * * @category Chat Actions * @version 1 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..2cec8fa7f 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 `IsRead` bit, the host MUST project that + * exact value into the matching `SessionChatSummary.isRead` 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..bca0f014b 100644 --- a/types/channels-session/state.ts +++ b/types/channels-session/state.ts @@ -543,6 +543,15 @@ export interface SessionChatSummary { * backward compatibility. */ interactivity?: ChatInteractivity; + /** + * Exact read state for this chat. + * + * Generic clients use this to present read or unread chats in session lists + * without subscribing to the session or chat channel. `true` means read and + * `false` means unread. Absence means unknown for backward compatibility and + * MUST NOT be interpreted as read. + */ + isRead?: boolean; /** * Whether this chat has been archived independently of its owning session * (see `chat/isArchivedChanged`). 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/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..d00038118 --- /dev/null +++ b/types/test-cases/round-trips/053-session-chat-summary-read-state-round-trip.json @@ -0,0 +1,58 @@ +{ + "name": "session-chat-summary-read-state-round-trip", + "group": "A", + "description": "SessionSummary chat catalog entries preserve exact read and unread states while allowing older hosts to omit the unknown read state.", + "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", + "isRead": true + }, + { + "resource": "ahp-chat:/worker", + "title": "Worker", + "isRead": false + }, + { + "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", + "isRead": true + }, + { + "resource": "ahp-chat:/worker", + "title": "Worker", + "isRead": false + }, + { + "resource": "ahp-chat:/legacy", + "title": "Legacy" + } + ], + "defaultChat": "ahp-chat:/lead" + } + ] +} From af04aca53a28a040c16ac0c1cf898c62bcfcaca9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 2 Oct 2026 12:01:56 +0200 Subject: [PATCH 5/9] rust: format chat read summary test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/rust/crates/ahp/src/hosts/runtime.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/clients/rust/crates/ahp/src/hosts/runtime.rs b/clients/rust/crates/ahp/src/hosts/runtime.rs index 504213294..cafc89da0 100644 --- a/clients/rust/crates/ahp/src/hosts/runtime.rs +++ b/clients/rust/crates/ahp/src/hosts/runtime.rs @@ -803,7 +803,11 @@ mod tests { apply_summary_changes(&mut summary, &changes); assert_eq!( - summary.chats.as_ref().and_then(|chats| chats.first()).and_then(|chat| chat.is_read), + summary + .chats + .as_ref() + .and_then(|chats| chats.first()) + .and_then(|chat| chat.is_read), Some(true) ); } From 70cbfd1fcb1526c4e6a829b231c7445b387c1ab9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 2 Oct 2026 18:54:20 +0200 Subject: [PATCH 6/9] chat: defer protocol version bump to the next release Keep chat/isReadChanged registered under the current development protocol version as requested in review. Release tracking in #491 will update the pending action version assignments together. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ---- clients/dotnet/release-metadata.json | 1 - .../Generated/Version.generated.cs | 3 +-- clients/go/ahptypes/version.generated.go | 3 +-- clients/go/release-metadata.json | 1 - clients/kotlin/release-metadata.json | 1 - .../agenthostprotocol/generated/Version.generated.kt | 3 +-- clients/rust/crates/ahp-types/src/version.rs | 7 +++---- clients/rust/release-metadata.json | 1 - .../AgentHostProtocol/Generated/Version.generated.swift | 3 +-- clients/swift/release-metadata.json | 1 - clients/typescript/release-metadata.json | 1 - types/version/registry.test.ts | 8 ++++---- types/version/registry.ts | 5 ++--- 14 files changed, 13 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cd8dc18f..c0687a6a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,6 @@ changes accumulate. Track in-flight protocol changes via PRs touching `NOTIFICATION_INTRODUCED_IN` maps in [`types/version/registry.ts`](types/version/registry.ts). -## [0.10.0] — Unreleased - -Spec version: `0.10.0` - ## [0.9.0] — 2026-08-28 Spec version: `0.9.0` diff --git a/clients/dotnet/release-metadata.json b/clients/dotnet/release-metadata.json index c745db9d9..128ac239f 100644 --- a/clients/dotnet/release-metadata.json +++ b/clients/dotnet/release-metadata.json @@ -2,7 +2,6 @@ "client": "dotnet", "packageVersion": "0.9.0", "supportedProtocolVersions": [ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs index 0418dbbb8..1f8c3437d 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs @@ -14,11 +14,10 @@ public static class ProtocolVersion /// The current protocol version (SemVer MAJOR.MINOR.PATCH) this /// generated source speaks. /// - public const string Current = "0.10.0"; + public const string Current = "0.9.0"; private static readonly string[] s_supported = { - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/go/ahptypes/version.generated.go b/clients/go/ahptypes/version.generated.go index 546b8583f..456d539c8 100644 --- a/clients/go/ahptypes/version.generated.go +++ b/clients/go/ahptypes/version.generated.go @@ -6,13 +6,12 @@ package ahptypes // ProtocolVersion is the current protocol version (SemVer // MAJOR.MINOR.PATCH) that this generated source speaks. -const ProtocolVersion = "0.10.0" +const ProtocolVersion = "0.9.0" // supportedProtocolVersions backs [SupportedProtocolVersions] — held // in an unexported slice so callers cannot accidentally mutate the // shared backing array. var supportedProtocolVersions = []string{ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/go/release-metadata.json b/clients/go/release-metadata.json index 3f1b9a894..4faee915d 100644 --- a/clients/go/release-metadata.json +++ b/clients/go/release-metadata.json @@ -2,7 +2,6 @@ "client": "go", "packageVersion": "0.9.0", "supportedProtocolVersions": [ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/kotlin/release-metadata.json b/clients/kotlin/release-metadata.json index 63e469469..bdf2a4881 100644 --- a/clients/kotlin/release-metadata.json +++ b/clients/kotlin/release-metadata.json @@ -2,7 +2,6 @@ "client": "kotlin", "packageVersion": "0.9.0", "supportedProtocolVersions": [ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt index d4da30550..31c2b348c 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt @@ -5,7 +5,7 @@ package com.microsoft.agenthostprotocol.generated /** * Current protocol version (SemVer `MAJOR.MINOR.PATCH`). */ -public const val PROTOCOL_VERSION: String = "0.10.0" +public const val PROTOCOL_VERSION: String = "0.9.0" /** * Every protocol version this library is willing to negotiate, ordered @@ -16,7 +16,6 @@ public const val PROTOCOL_VERSION: String = "0.10.0" * protocol versions if the host doesn't accept the newest one. */ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/rust/crates/ahp-types/src/version.rs b/clients/rust/crates/ahp-types/src/version.rs index 042f44922..15cc81d3e 100644 --- a/clients/rust/crates/ahp-types/src/version.rs +++ b/clients/rust/crates/ahp-types/src/version.rs @@ -5,7 +5,7 @@ #![allow(missing_docs)] /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -pub const PROTOCOL_VERSION: &str = "0.10.0"; +pub const PROTOCOL_VERSION: &str = "0.9.0"; /// Every protocol version this crate is willing to negotiate, ordered /// most-preferred-first. The first entry equals [`PROTOCOL_VERSION`]. @@ -13,6 +13,5 @@ pub const PROTOCOL_VERSION: &str = "0.10.0"; /// Consumers building `InitializeParams` should pass this slice (or a /// derived `Vec`) so the same client binary can fall back to /// older protocol versions if the host doesn't accept the newest one. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.2", "0.5.1", -]; +pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = + &["0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.2", "0.5.1"]; diff --git a/clients/rust/release-metadata.json b/clients/rust/release-metadata.json index a756c2ac8..b93f3893f 100644 --- a/clients/rust/release-metadata.json +++ b/clients/rust/release-metadata.json @@ -2,7 +2,6 @@ "client": "rust", "packageVersion": "0.9.0", "supportedProtocolVersions": [ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift index 35825e387..b85278501 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift @@ -3,7 +3,7 @@ import Foundation /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -public let PROTOCOL_VERSION: String = "0.10.0" +public let PROTOCOL_VERSION: String = "0.9.0" /// Every protocol version this package is willing to negotiate, /// ordered most-preferred-first. The first entry equals @@ -13,7 +13,6 @@ public let PROTOCOL_VERSION: String = "0.10.0" /// `InitializeParams` so the same client binary can fall back to older /// protocol versions if the host doesn't accept the newest one. public let SUPPORTED_PROTOCOL_VERSIONS: [String] = [ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/swift/release-metadata.json b/clients/swift/release-metadata.json index 485b6ec3d..e5693c0d4 100644 --- a/clients/swift/release-metadata.json +++ b/clients/swift/release-metadata.json @@ -2,7 +2,6 @@ "client": "swift", "packageVersion": "0.9.0", "supportedProtocolVersions": [ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/typescript/release-metadata.json b/clients/typescript/release-metadata.json index c7d90aa5d..1925c9293 100644 --- a/clients/typescript/release-metadata.json +++ b/clients/typescript/release-metadata.json @@ -2,7 +2,6 @@ "client": "typescript", "packageVersion": "0.9.0", "supportedProtocolVersions": [ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/types/version/registry.test.ts b/types/version/registry.test.ts index 0cbce6066..93509bab3 100644 --- a/types/version/registry.test.ts +++ b/types/version/registry.test.ts @@ -71,15 +71,15 @@ 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.10.0', () => { +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.10.0'); - assert.equal(isActionKnownToVersion(action, '0.9.0'), false); - assert.equal(isActionKnownToVersion(action, '0.10.0'), true); + 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 () => { diff --git a/types/version/registry.ts b/types/version/registry.ts index 40cc49851..6da36441e 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -15,7 +15,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '0.10.0'; +export const PROTOCOL_VERSION = '0.9.0'; /** * Every protocol version a client built from this source tree is willing @@ -34,7 +34,6 @@ export const PROTOCOL_VERSION = '0.10.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ - '0.10.0', '0.9.0', '0.8.0', '0.7.0', @@ -142,7 +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.10.0', + [ActionType.ChatIsReadChanged]: '0.9.0', [ActionType.ChatIsArchivedChanged]: '0.9.0', [ActionType.ChatInputRequested]: '0.4.0', [ActionType.ChatInputAnswerChanged]: '0.4.0', From 9cd3ff5bce6458290df429619ba6d568e1791891 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 2 Oct 2026 19:15:24 +0200 Subject: [PATCH 7/9] chat: unify compact read and archive state in status Replace the unreleased SessionChatSummary isRead and archived fields with the required SessionStatus bitset. Keep compact and full chat summary projections consistent, regenerate native bindings and schemas, and cover combined and future flags in the shared round-trip corpus. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generated/Actions.generated.cs | 6 ++--- .../Generated/State.generated.cs | 22 +++++---------- .../FixRegressionTests.cs | 8 +++--- clients/go/ahptypes/actions.generated.go | 6 ++--- clients/go/ahptypes/state.generated.go | 21 +++++---------- .../generated/State.generated.kt | 21 +++++---------- clients/rust/crates/ahp-types/src/actions.rs | 6 ++--- clients/rust/crates/ahp-types/src/state.rs | 21 +++++---------- clients/rust/crates/ahp/src/hosts/runtime.rs | 15 +++++++---- .../Generated/State.generated.swift | 27 +++++++------------ clients/typescript/test/hosts.test.ts | 10 +++---- ...0260929-session-chat-summary-archived.json | 2 +- .../20261001-chat-read-state-action.json | 2 +- docs/guide/actions.md | 2 +- docs/guide/state-model.md | 14 +++++----- docs/specification/chat-channel.md | 2 +- docs/specification/session-channel.md | 10 +++---- schema/actions.schema.json | 17 +++++------- schema/commands.schema.json | 17 +++++------- schema/errors.schema.json | 17 +++++------- schema/notifications.schema.json | 13 ++++----- schema/state.schema.json | 13 ++++----- types/channels-chat/actions.ts | 2 +- types/channels-session/actions.ts | 4 +-- types/channels-session/state.ts | 21 +++++---------- ...chat-summary-interactivity-round-trip.json | 4 +++ ...sion-chat-summary-archived-round-trip.json | 12 +++++---- ...ssion-chat-summary-changes-round-trip.json | 8 ++++-- ...on-chat-summary-read-state-round-trip.json | 20 +++++++------- 29 files changed, 146 insertions(+), 197 deletions(-) diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index 532568148..e8a2d26d4 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -1162,8 +1162,8 @@ public sealed record SessionChatRemovedAction /// SHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}. /// /// Mirrors the root-channel `root/sessionSummaryChanged` notification. -/// When `changes.status` changes the `IsRead` bit, the host MUST project that -/// exact value into the matching `SessionChatSummary.isRead` field and publish +/// 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 { @@ -2028,7 +2028,7 @@ public sealed record ChatDraftChangedAction /// 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.isRead` projections. +/// `SessionChatSummary.status` projections. public sealed record ChatIsReadChangedAction { public ActionType Type { get; init; } = ActionType.ChatIsReadChanged; diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index ee3b97315..5733e484e 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -3168,23 +3168,13 @@ public sealed record SessionChatSummary [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ChatInteractivity? Interactivity { get; init; } - /// Exact read state for this chat. + /// Current chat status, matching {@link ChatSummary.status}. /// - /// Generic clients use this to present read or unread chats in session lists - /// without subscribing to the session or chat channel. `true` means read and - /// `false` means unread. Absence means unknown for backward compatibility and - /// MUST NOT be interpreted as read. - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? IsRead { get; init; } - - /// 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. - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? Archived { get; init; } + /// 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. + public SessionStatus Status { get; init; } /// Aggregate summary of file changes associated with this chat. /// diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs index 21438b990..a5fbe532d 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs @@ -311,7 +311,7 @@ public void ApplySummaryChange_Meta_OverridesWhenPresent_CarriesOverWhenAbsent() } [Fact] - public void ApplySummaryChange_Chats_ReplacesCompactReadProjection() + public void ApplySummaryChange_Chats_ReplacesCompactStatusProjection() { var entry = new HostEntry( new HostId("h"), @@ -334,7 +334,7 @@ public void ApplySummaryChange_Chats_ReplacesCompactReadProjection() { Resource = "ahp-chat:/default", Title = "Default", - IsRead = false, + Status = SessionStatus.Idle, }, ], }); @@ -347,13 +347,13 @@ public void ApplySummaryChange_Chats_ReplacesCompactReadProjection() { Resource = "ahp-chat:/default", Title = "Default", - IsRead = true, + Status = SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived, }, ], }); var summary = entry.Snapshot().SessionSummaries.Single(s => s.Resource == "ahp-session:/s1"); - Assert.True(Assert.Single(summary.Chats!).IsRead); + Assert.Equal(SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived, Assert.Single(summary.Chats!).Status); } // ── Upstream drift port (model config widened to JSON primitives; SessionModelInfo diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index a6ce87f41..986ba6ddf 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -212,8 +212,8 @@ type SessionChatRemovedAction struct { // SHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}. // // Mirrors the root-channel `root/sessionSummaryChanged` notification. -// When `changes.status` changes the `IsRead` bit, the host MUST project that -// exact value into the matching `SessionChatSummary.isRead` field and publish +// 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"` @@ -825,7 +825,7 @@ type ChatDraftChangedAction struct { // 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.isRead` projections. +// `SessionChatSummary.status` projections. type ChatIsReadChangedAction struct { Type ActionType `json:"type"` // Whether the chat has been read diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index e4a9d2f7c..968e59383 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -1229,20 +1229,13 @@ type SessionChatSummary struct { // read-only chats. Absence defaults to {@link ChatInteractivity.Full} for // backward compatibility. Interactivity *ChatInteractivity `json:"interactivity,omitempty"` - // Exact read state for this chat. - // - // Generic clients use this to present read or unread chats in session lists - // without subscribing to the session or chat channel. `true` means read and - // `false` means unread. Absence means unknown for backward compatibility and - // MUST NOT be interpreted as read. - IsRead *bool `json:"isRead,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. + Status SessionStatus `json:"status"` // 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/generated/State.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt index 7cc06ae59..c3155e20d 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,23 +2290,14 @@ data class SessionChatSummary( */ val interactivity: ChatInteractivity? = null, /** - * Exact read state for this chat. + * Current chat status, matching {@link ChatSummary.status}. * - * Generic clients use this to present read or unread chats in session lists - * without subscribing to the session or chat channel. `true` means read and - * `false` means unread. Absence means unknown for backward compatibility and - * MUST NOT be interpreted as read. + * 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. */ - val isRead: Boolean? = null, - /** - * 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. - */ - val archived: Boolean? = null, + val status: SessionStatus, /** * 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 0638c2465..7a28a266f 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -538,8 +538,8 @@ pub struct SessionChatRemovedAction { /// SHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}. /// /// Mirrors the root-channel `root/sessionSummaryChanged` notification. -/// When `changes.status` changes the `IsRead` bit, the host MUST project that -/// exact value into the matching `SessionChatSummary.isRead` field and publish +/// 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")] @@ -1490,7 +1490,7 @@ pub struct ChatDraftChangedAction { /// 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.isRead` projections. +/// `SessionChatSummary.status` projections. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatIsReadChangedAction { diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index c7131b9b5..dd0f1c964 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -2571,22 +2571,13 @@ pub struct SessionChatSummary { /// backward compatibility. #[serde(default, skip_serializing_if = "Option::is_none")] pub interactivity: Option, - /// Exact read state for this chat. + /// Current chat status, matching {@link ChatSummary.status}. /// - /// Generic clients use this to present read or unread chats in session lists - /// without subscribing to the session or chat channel. `true` means read and - /// `false` means unread. Absence means unknown for backward compatibility and - /// MUST NOT be interpreted as read. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub is_read: Option, - /// 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. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub archived: Option, + /// 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. + pub status: u32, /// 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 cafc89da0..79371c55e 100644 --- a/clients/rust/crates/ahp/src/hosts/runtime.rs +++ b/clients/rust/crates/ahp/src/hosts/runtime.rs @@ -776,7 +776,7 @@ mod tests { use serde_json::json; #[test] - fn summary_changes_replace_compact_chat_read_projection() { + fn summary_changes_replace_compact_chat_status_projection() { let mut summary: SessionSummary = serde_json::from_value(json!({ "resource": "ahp-session:/s1", "provider": "copilot", @@ -787,7 +787,7 @@ mod tests { "chats": [{ "resource": "ahp-chat:/default", "title": "Default", - "isRead": false + "status": 1 }] })) .expect("valid session summary"); @@ -795,7 +795,7 @@ mod tests { "chats": [{ "resource": "ahp-chat:/default", "title": "Default", - "isRead": true + "status": 97 }] })) .expect("valid session summary changes"); @@ -807,8 +807,13 @@ mod tests { .chats .as_ref() .and_then(|chats| chats.first()) - .and_then(|chat| chat.is_read), - Some(true) + .map(|chat| chat.status), + Some( + (ahp_types::state::SessionStatus::Idle + | ahp_types::state::SessionStatus::IsRead + | ahp_types::state::SessionStatus::IsArchived) + .bits() + ) ); } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 9894d683a..49a4c9ca5 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -2439,20 +2439,13 @@ public struct SessionChatSummary: Codable, Sendable { /// read-only chats. Absence defaults to {@link ChatInteractivity.Full} for /// backward compatibility. public var interactivity: ChatInteractivity? - /// Exact read state for this chat. - /// - /// Generic clients use this to present read or unread chats in session lists - /// without subscribing to the session or chat channel. `true` means read and - /// `false` means unread. Absence means unknown for backward compatibility and - /// MUST NOT be interpreted as read. - public var isRead: Bool? - /// 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. + 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 @@ -2465,16 +2458,14 @@ public struct SessionChatSummary: Codable, Sendable { title: String, origin: ChatOrigin? = nil, interactivity: ChatInteractivity? = nil, - isRead: Bool? = nil, - archived: Bool? = nil, + status: SessionStatus, changes: ChangesSummary? = nil ) { self.resource = resource self.title = title self.origin = origin self.interactivity = interactivity - self.isRead = isRead - self.archived = archived + self.status = status self.changes = changes } } diff --git a/clients/typescript/test/hosts.test.ts b/clients/typescript/test/hosts.test.ts index f46fadb6b..fe8ffd2e3 100644 --- a/clients/typescript/test/hosts.test.ts +++ b/clients/typescript/test/hosts.test.ts @@ -616,10 +616,10 @@ test('aggregatedSessions sorts by modifiedAt descending and tags hostLabel', asy } }); -test('sessionSummaryChanged replaces the compact chat read projection', async () => { +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', isRead: false }, + { resource: 'ahp-chat:/default', title: 'Default', status: SessionStatus.Idle }, ]; const state: FakeHostState = makeFakeState({ sessions: [initial], @@ -633,7 +633,7 @@ test('sessionSummaryChanged replaces the compact chat read projection', async () session: initial.resource, changes: { chats: [ - { resource: 'ahp-chat:/default', title: 'Default', isRead: true }, + { resource: 'ahp-chat:/default', title: 'Default', status: SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived }, ], }, }, @@ -654,11 +654,11 @@ test('sessionSummaryChanged replaces the compact chat read projection', async () transportFactory: makeBasicFactory(state), }); await waitUntil(() => - multi.aggregatedSessions()[0]?.summary.chats?.[0]?.isRead === true + 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', isRead: true }, + { resource: 'ahp-chat:/default', title: 'Default', status: SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived }, ]); } finally { await multi.shutdown(); 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 index 5a969cb88..a0d37c403 100644 --- a/docs/.changes/20261001-chat-read-state-action.json +++ b/docs/.changes/20261001-chat-read-state-action.json @@ -1,4 +1,4 @@ { "type": "added", - "message": "`chat/isReadChanged` action and `SessionChatSummary.isRead` projection for independently tracking the exact read state of any known chat, including the default chat." + "message": "`chat/isReadChanged` action and `SessionChatSummary.status` projection for independently tracking the status of any known chat, including the default chat; the required status bitset replaces the unreleased `isRead` and `archived` catalog fields." } diff --git a/docs/guide/actions.md b/docs/guide/actions.md index 09d4bdcb4..2b793756a 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -215,7 +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.isRead` projection | +| `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 6f0c54976..19441908b 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -112,7 +112,7 @@ SessionSummary { workingDirectories?: URI[] // equal-peer working directories annotations?: AnnotationsSummary changes?: ChangesSummary - chats?: SessionChatSummary[] // compact list presentation, including exact per-chat read state when known + chats?: SessionChatSummary[] // compact list presentation, including per-chat status } ProjectInfo { @@ -121,12 +121,12 @@ ProjectInfo { } ``` -`SessionChatSummary.isRead` is optional for backward compatibility. `true` -means the chat is read, `false` means it is unread, and absence means unknown; -clients must not interpret an absent value as read. Hosts update this compact -projection alongside `ChatSummary.status` when `chat/isReadChanged` is -accepted, so session lists can restore exact per-chat read state without -subscribing to every session or chat. +`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 `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. diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index a40e8be3c..469908f52 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -30,7 +30,7 @@ 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.isRead` projection synchronized through +matching compact `SessionChatSummary.status` projection synchronized through `root/sessionSummaryChanged`. `session/isReadChanged` independently changes the owning session's read state; neither action implies the other. diff --git a/docs/specification/session-channel.md b/docs/specification/session-channel.md index 36a960838..9e6ccbc4a 100644 --- a/docs/specification/session-channel.md +++ b/docs/specification/session-channel.md @@ -65,13 +65,13 @@ 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 `SessionStatus.IsRead` bit changes, the producer MUST project its +When a chat's `status` changes, the producer MUST project its exact value into the matching -[`SessionChatSummary.isRead`](/reference/session#sessionchatsummary) field and +[`SessionChatSummary.status`](/reference/session#sessionchatsummary) field and publish the complete compact `SessionSummary.chats` catalog through -`root/sessionSummaryChanged`. `true` means read and `false` means unread. -Absence means the host did not provide the projection and clients MUST treat -the state as unknown, not as read. +`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. 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 4dc7e1cb9..d903f6d89 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.\nWhen `changes.status` changes the `IsRead` bit, the host MUST project that\nexact value into the matching `SessionChatSummary.isRead` field and publish\nthe updated compact chat catalog through `root/sessionSummaryChanged`.", + "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" @@ -1657,7 +1657,7 @@ }, "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.isRead` projections.", + "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" @@ -3765,13 +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." }, - "isRead": { - "type": "boolean", - "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." - }, - "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." }, "changes": { "$ref": "#/$defs/ChangesSummary", @@ -3780,7 +3776,8 @@ }, "required": [ "resource", - "title" + "title", + "status" ] }, "ChangesSummary": { diff --git a/schema/commands.schema.json b/schema/commands.schema.json index a6c6d0295..922737659 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -2980,13 +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." }, - "isRead": { - "type": "boolean", - "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." - }, - "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." }, "changes": { "$ref": "#/$defs/ChangesSummary", @@ -2995,7 +2991,8 @@ }, "required": [ "resource", - "title" + "title", + "status" ] }, "ChangesSummary": { @@ -7851,7 +7848,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.\nWhen `changes.status` changes the `IsRead` bit, the host MUST project that\nexact value into the matching `SessionChatSummary.isRead` field and publish\nthe updated compact chat catalog through `root/sessionSummaryChanged`.", + "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" @@ -9316,7 +9313,7 @@ }, "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.isRead` projections.", + "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" diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 1af3e7b5e..7aa77125f 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -1243,13 +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." }, - "isRead": { - "type": "boolean", - "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." - }, - "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." }, "changes": { "$ref": "#/$defs/ChangesSummary", @@ -1258,7 +1254,8 @@ }, "required": [ "resource", - "title" + "title", + "status" ] }, "ChangesSummary": { @@ -9015,7 +9012,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.\nWhen `changes.status` changes the `IsRead` bit, the host MUST project that\nexact value into the matching `SessionChatSummary.isRead` field and publish\nthe updated compact chat catalog through `root/sessionSummaryChanged`.", + "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" @@ -10329,7 +10326,7 @@ }, "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.isRead` projections.", + "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" diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 91ebfc422..689c986f6 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -1421,13 +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." }, - "isRead": { - "type": "boolean", - "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." - }, - "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." }, "changes": { "$ref": "#/$defs/ChangesSummary", @@ -1436,7 +1432,8 @@ }, "required": [ "resource", - "title" + "title", + "status" ] }, "ChangesSummary": { diff --git a/schema/state.schema.json b/schema/state.schema.json index 53c4931ab..c47d89323 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -1154,13 +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." }, - "isRead": { - "type": "boolean", - "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." - }, - "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." }, "changes": { "$ref": "#/$defs/ChangesSummary", @@ -1169,7 +1165,8 @@ }, "required": [ "resource", - "title" + "title", + "status" ] }, "ChangesSummary": { diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index 5f5d71058..8102468b1 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -845,7 +845,7 @@ export interface ChatDraftChangedAction { * 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.isRead` projections. + * `SessionChatSummary.status` projections. * * @category Chat Actions * @version 1 diff --git a/types/channels-session/actions.ts b/types/channels-session/actions.ts index 2cec8fa7f..9e54a3319 100644 --- a/types/channels-session/actions.ts +++ b/types/channels-session/actions.ts @@ -81,8 +81,8 @@ export interface SessionChatRemovedAction { * SHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}. * * Mirrors the root-channel `root/sessionSummaryChanged` notification. - * When `changes.status` changes the `IsRead` bit, the host MUST project that - * exact value into the matching `SessionChatSummary.isRead` field and publish + * 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 diff --git a/types/channels-session/state.ts b/types/channels-session/state.ts index bca0f014b..d556ba58a 100644 --- a/types/channels-session/state.ts +++ b/types/channels-session/state.ts @@ -544,23 +544,14 @@ export interface SessionChatSummary { */ interactivity?: ChatInteractivity; /** - * Exact read state for this chat. + * Current chat status, matching {@link ChatSummary.status}. * - * Generic clients use this to present read or unread chats in session lists - * without subscribing to the session or chat channel. `true` means read and - * `false` means unread. Absence means unknown for backward compatibility and - * MUST NOT be interpreted as read. + * 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. */ - isRead?: boolean; - /** - * 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?: boolean; + status: SessionStatus; /** * Aggregate summary of file changes associated with this chat. * 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..026d45e4c 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 required 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 index d00038118..994ab1554 100644 --- 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 @@ -1,7 +1,7 @@ { "name": "session-chat-summary-read-state-round-trip", "group": "A", - "description": "SessionSummary chat catalog entries preserve exact read and unread states while allowing older hosts to omit the unknown read state.", + "description": "SessionSummary chat catalog entries preserve read, unread, activity, archived, and unknown future bits in the required status bitset.", "type": "SessionSummary", "input": { "resource": "ahp-session:/s1", @@ -14,16 +14,17 @@ { "resource": "ahp-chat:/lead", "title": "Lead", - "isRead": true + "status": 33 }, { "resource": "ahp-chat:/worker", "title": "Worker", - "isRead": false + "status": 24 }, { - "resource": "ahp-chat:/legacy", - "title": "Legacy" + "resource": "ahp-chat:/future", + "title": "Future", + "status": 225 } ], "defaultChat": "ahp-chat:/lead" @@ -40,16 +41,17 @@ { "resource": "ahp-chat:/lead", "title": "Lead", - "isRead": true + "status": 33 }, { "resource": "ahp-chat:/worker", "title": "Worker", - "isRead": false + "status": 24 }, { - "resource": "ahp-chat:/legacy", - "title": "Legacy" + "resource": "ahp-chat:/future", + "title": "Future", + "status": 225 } ], "defaultChat": "ahp-chat:/lead" From ab8c9ffd37c63ca6799606252deba8f24e4aba7d Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 2 Oct 2026 19:21:24 +0200 Subject: [PATCH 8/9] Revert "chat: unify compact read and archive state in status" This reverts commit 9cd3ff5bce6458290df429619ba6d568e1791891. Defer the required compact status field until the consuming client is ready for the adoption work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generated/Actions.generated.cs | 6 ++--- .../Generated/State.generated.cs | 22 ++++++++++----- .../FixRegressionTests.cs | 8 +++--- clients/go/ahptypes/actions.generated.go | 6 ++--- clients/go/ahptypes/state.generated.go | 21 ++++++++++----- .../generated/State.generated.kt | 21 ++++++++++----- clients/rust/crates/ahp-types/src/actions.rs | 6 ++--- clients/rust/crates/ahp-types/src/state.rs | 21 ++++++++++----- clients/rust/crates/ahp/src/hosts/runtime.rs | 15 ++++------- .../Generated/State.generated.swift | 27 ++++++++++++------- clients/typescript/test/hosts.test.ts | 10 +++---- ...0260929-session-chat-summary-archived.json | 2 +- .../20261001-chat-read-state-action.json | 2 +- docs/guide/actions.md | 2 +- docs/guide/state-model.md | 14 +++++----- docs/specification/chat-channel.md | 2 +- docs/specification/session-channel.md | 10 +++---- schema/actions.schema.json | 17 +++++++----- schema/commands.schema.json | 17 +++++++----- schema/errors.schema.json | 17 +++++++----- schema/notifications.schema.json | 13 +++++---- schema/state.schema.json | 13 +++++---- types/channels-chat/actions.ts | 2 +- types/channels-session/actions.ts | 4 +-- types/channels-session/state.ts | 21 ++++++++++----- ...chat-summary-interactivity-round-trip.json | 4 --- ...sion-chat-summary-archived-round-trip.json | 12 ++++----- ...ssion-chat-summary-changes-round-trip.json | 8 ++---- ...on-chat-summary-read-state-round-trip.json | 20 +++++++------- 29 files changed, 197 insertions(+), 146 deletions(-) diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index e8a2d26d4..532568148 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -1162,8 +1162,8 @@ public sealed record 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 +/// When `changes.status` changes the `IsRead` bit, the host MUST project that +/// exact value into the matching `SessionChatSummary.isRead` field and publish /// the updated compact chat catalog through `root/sessionSummaryChanged`. public sealed record SessionChatUpdatedAction { @@ -2028,7 +2028,7 @@ public sealed record ChatDraftChangedAction /// 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. +/// `SessionChatSummary.isRead` projections. public sealed record ChatIsReadChangedAction { public ActionType Type { get; init; } = ActionType.ChatIsReadChanged; diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index 5733e484e..ee3b97315 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -3168,13 +3168,23 @@ public sealed record SessionChatSummary [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ChatInteractivity? Interactivity { get; init; } - /// Current chat status, matching {@link ChatSummary.status}. + /// Exact read state for this chat. /// - /// 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. - public SessionStatus Status { get; init; } + /// Generic clients use this to present read or unread chats in session lists + /// without subscribing to the session or chat channel. `true` means read and + /// `false` means unread. Absence means unknown for backward compatibility and + /// MUST NOT be interpreted as read. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? IsRead { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Archived { get; init; } /// Aggregate summary of file changes associated with this chat. /// diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs index a5fbe532d..21438b990 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs @@ -311,7 +311,7 @@ public void ApplySummaryChange_Meta_OverridesWhenPresent_CarriesOverWhenAbsent() } [Fact] - public void ApplySummaryChange_Chats_ReplacesCompactStatusProjection() + public void ApplySummaryChange_Chats_ReplacesCompactReadProjection() { var entry = new HostEntry( new HostId("h"), @@ -334,7 +334,7 @@ public void ApplySummaryChange_Chats_ReplacesCompactStatusProjection() { Resource = "ahp-chat:/default", Title = "Default", - Status = SessionStatus.Idle, + IsRead = false, }, ], }); @@ -347,13 +347,13 @@ public void ApplySummaryChange_Chats_ReplacesCompactStatusProjection() { Resource = "ahp-chat:/default", Title = "Default", - Status = SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived, + IsRead = true, }, ], }); 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); + Assert.True(Assert.Single(summary.Chats!).IsRead); } // ── Upstream drift port (model config widened to JSON primitives; SessionModelInfo diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index 986ba6ddf..a6ce87f41 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -212,8 +212,8 @@ 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 +// When `changes.status` changes the `IsRead` bit, the host MUST project that +// exact value into the matching `SessionChatSummary.isRead` field and publish // the updated compact chat catalog through `root/sessionSummaryChanged`. type SessionChatUpdatedAction struct { Type ActionType `json:"type"` @@ -825,7 +825,7 @@ type ChatDraftChangedAction struct { // 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. +// `SessionChatSummary.isRead` projections. type ChatIsReadChangedAction struct { Type ActionType `json:"type"` // Whether the chat has been read diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 968e59383..e4a9d2f7c 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -1229,13 +1229,20 @@ type SessionChatSummary struct { // read-only chats. Absence defaults to {@link ChatInteractivity.Full} for // backward compatibility. Interactivity *ChatInteractivity `json:"interactivity,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. - Status SessionStatus `json:"status"` + // Exact read state for this chat. + // + // Generic clients use this to present read or unread chats in session lists + // without subscribing to the session or chat channel. `true` means read and + // `false` means unread. Absence means unknown for backward compatibility and + // MUST NOT be interpreted as read. + IsRead *bool `json:"isRead,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"` // 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/generated/State.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt index c3155e20d..7cc06ae59 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,23 @@ data class SessionChatSummary( */ val interactivity: ChatInteractivity? = null, /** - * Current chat status, matching {@link ChatSummary.status}. + * Exact read state for this chat. * - * 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. + * Generic clients use this to present read or unread chats in session lists + * without subscribing to the session or chat channel. `true` means read and + * `false` means unread. Absence means unknown for backward compatibility and + * MUST NOT be interpreted as read. */ - val status: SessionStatus, + val isRead: Boolean? = null, + /** + * 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. + */ + val archived: Boolean? = 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 7a28a266f..0638c2465 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -538,8 +538,8 @@ 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 +/// When `changes.status` changes the `IsRead` bit, the host MUST project that +/// exact value into the matching `SessionChatSummary.isRead` field and publish /// the updated compact chat catalog through `root/sessionSummaryChanged`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1490,7 +1490,7 @@ pub struct ChatDraftChangedAction { /// 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. +/// `SessionChatSummary.isRead` projections. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatIsReadChangedAction { diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index dd0f1c964..c7131b9b5 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -2571,13 +2571,22 @@ pub struct SessionChatSummary { /// backward compatibility. #[serde(default, skip_serializing_if = "Option::is_none")] pub interactivity: Option, - /// Current chat status, matching {@link ChatSummary.status}. + /// Exact read state for this chat. /// - /// 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. - pub status: u32, + /// Generic clients use this to present read or unread chats in session lists + /// without subscribing to the session or chat channel. `true` means read and + /// `false` means unread. Absence means unknown for backward compatibility and + /// MUST NOT be interpreted as read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_read: Option, + /// 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. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archived: 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 79371c55e..cafc89da0 100644 --- a/clients/rust/crates/ahp/src/hosts/runtime.rs +++ b/clients/rust/crates/ahp/src/hosts/runtime.rs @@ -776,7 +776,7 @@ mod tests { use serde_json::json; #[test] - fn summary_changes_replace_compact_chat_status_projection() { + fn summary_changes_replace_compact_chat_read_projection() { let mut summary: SessionSummary = serde_json::from_value(json!({ "resource": "ahp-session:/s1", "provider": "copilot", @@ -787,7 +787,7 @@ mod tests { "chats": [{ "resource": "ahp-chat:/default", "title": "Default", - "status": 1 + "isRead": false }] })) .expect("valid session summary"); @@ -795,7 +795,7 @@ mod tests { "chats": [{ "resource": "ahp-chat:/default", "title": "Default", - "status": 97 + "isRead": true }] })) .expect("valid session summary changes"); @@ -807,13 +807,8 @@ mod tests { .chats .as_ref() .and_then(|chats| chats.first()) - .map(|chat| chat.status), - Some( - (ahp_types::state::SessionStatus::Idle - | ahp_types::state::SessionStatus::IsRead - | ahp_types::state::SessionStatus::IsArchived) - .bits() - ) + .and_then(|chat| chat.is_read), + Some(true) ); } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 49a4c9ca5..9894d683a 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -2439,13 +2439,20 @@ public struct SessionChatSummary: Codable, Sendable { /// read-only chats. Absence defaults to {@link ChatInteractivity.Full} for /// backward compatibility. public var interactivity: ChatInteractivity? - /// 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. - public var status: SessionStatus + /// Exact read state for this chat. + /// + /// Generic clients use this to present read or unread chats in session lists + /// without subscribing to the session or chat channel. `true` means read and + /// `false` means unread. Absence means unknown for backward compatibility and + /// MUST NOT be interpreted as read. + public var isRead: Bool? + /// 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? /// Aggregate summary of file changes associated with this chat. /// /// Servers may populate this so session lists can show per-chat change @@ -2458,14 +2465,16 @@ public struct SessionChatSummary: Codable, Sendable { title: String, origin: ChatOrigin? = nil, interactivity: ChatInteractivity? = nil, - status: SessionStatus, + isRead: Bool? = nil, + archived: Bool? = nil, changes: ChangesSummary? = nil ) { self.resource = resource self.title = title self.origin = origin self.interactivity = interactivity - self.status = status + self.isRead = isRead + self.archived = archived self.changes = changes } } diff --git a/clients/typescript/test/hosts.test.ts b/clients/typescript/test/hosts.test.ts index fe8ffd2e3..f46fadb6b 100644 --- a/clients/typescript/test/hosts.test.ts +++ b/clients/typescript/test/hosts.test.ts @@ -616,10 +616,10 @@ test('aggregatedSessions sorts by modifiedAt descending and tags hostLabel', asy } }); -test('sessionSummaryChanged replaces the compact chat status projection', async () => { +test('sessionSummaryChanged replaces the compact chat read projection', async () => { const initial = makeSummary('copilot:/s1', 'Session', 1_000); initial.chats = [ - { resource: 'ahp-chat:/default', title: 'Default', status: SessionStatus.Idle }, + { resource: 'ahp-chat:/default', title: 'Default', isRead: false }, ]; const state: FakeHostState = makeFakeState({ sessions: [initial], @@ -633,7 +633,7 @@ test('sessionSummaryChanged replaces the compact chat status projection', async session: initial.resource, changes: { chats: [ - { resource: 'ahp-chat:/default', title: 'Default', status: SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived }, + { resource: 'ahp-chat:/default', title: 'Default', isRead: true }, ], }, }, @@ -654,11 +654,11 @@ test('sessionSummaryChanged replaces the compact chat status projection', async transportFactory: makeBasicFactory(state), }); await waitUntil(() => - multi.aggregatedSessions()[0]?.summary.chats?.[0]?.status === (SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived) + multi.aggregatedSessions()[0]?.summary.chats?.[0]?.isRead === true ); assert.deepEqual(multi.aggregatedSessions()[0]?.summary.chats, [ - { resource: 'ahp-chat:/default', title: 'Default', status: SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived }, + { resource: 'ahp-chat:/default', title: 'Default', isRead: true }, ]); } finally { await multi.shutdown(); diff --git a/docs/.changes/20260929-session-chat-summary-archived.json b/docs/.changes/20260929-session-chat-summary-archived.json index 208a962fb..20a9f0901 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.status` exposes per-chat archived state via `SessionStatus.IsArchived` in the lightweight chat catalog without requiring a session subscription." + "message": "`SessionChatSummary.archived` exposes per-chat archived state 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 index a0d37c403..5a969cb88 100644 --- a/docs/.changes/20261001-chat-read-state-action.json +++ b/docs/.changes/20261001-chat-read-state-action.json @@ -1,4 +1,4 @@ { "type": "added", - "message": "`chat/isReadChanged` action and `SessionChatSummary.status` projection for independently tracking the status of any known chat, including the default chat; the required status bitset replaces the unreleased `isRead` and `archived` catalog fields." + "message": "`chat/isReadChanged` action and `SessionChatSummary.isRead` projection for independently tracking the exact read state of any known chat, including the default chat." } diff --git a/docs/guide/actions.md b/docs/guide/actions.md index 2b793756a..09d4bdcb4 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -215,7 +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 | +| `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.isRead` projection | | `session/isArchivedChanged` | Archives or unarchives the session | ## Reducers diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index 19441908b..6f0c54976 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -112,7 +112,7 @@ SessionSummary { workingDirectories?: URI[] // equal-peer working directories annotations?: AnnotationsSummary changes?: ChangesSummary - chats?: SessionChatSummary[] // compact list presentation, including per-chat status + chats?: SessionChatSummary[] // compact list presentation, including exact per-chat read state when known } ProjectInfo { @@ -121,12 +121,12 @@ 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. +`SessionChatSummary.isRead` is optional for backward compatibility. `true` +means the chat is read, `false` means it is unread, and absence means unknown; +clients must not interpret an absent value as read. Hosts update this compact +projection alongside `ChatSummary.status` when `chat/isReadChanged` is +accepted, so session lists can restore exact per-chat read state without +subscribing to every session or chat. 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. diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 469908f52..a40e8be3c 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -30,7 +30,7 @@ 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 +matching compact `SessionChatSummary.isRead` projection synchronized through `root/sessionSummaryChanged`. `session/isReadChanged` independently changes the owning session's read state; neither action implies the other. diff --git a/docs/specification/session-channel.md b/docs/specification/session-channel.md index 9e6ccbc4a..36a960838 100644 --- a/docs/specification/session-channel.md +++ b/docs/specification/session-channel.md @@ -65,13 +65,13 @@ 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 +When a chat's `SessionStatus.IsRead` bit changes, the producer MUST project its exact value into the matching -[`SessionChatSummary.status`](/reference/session#sessionchatsummary) field and +[`SessionChatSummary.isRead`](/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. +`root/sessionSummaryChanged`. `true` means read and `false` means unread. +Absence means the host did not provide the projection and clients MUST treat +the state as unknown, not as read. 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 d903f6d89..4dc7e1cb9 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.\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`.", + "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 `IsRead` bit, the host MUST project that\nexact value into the matching `SessionChatSummary.isRead` field and publish\nthe updated compact chat catalog through `root/sessionSummaryChanged`.", "properties": { "type": { "const": "session/chatUpdated" @@ -1657,7 +1657,7 @@ }, "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.", + "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.isRead` projections.", "properties": { "type": { "const": "chat/isReadChanged" @@ -3765,9 +3765,13 @@ "$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." }, - "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." + "isRead": { + "type": "boolean", + "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." + }, + "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." }, "changes": { "$ref": "#/$defs/ChangesSummary", @@ -3776,8 +3780,7 @@ }, "required": [ "resource", - "title", - "status" + "title" ] }, "ChangesSummary": { diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 922737659..a6c6d0295 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -2980,9 +2980,13 @@ "$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." }, - "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." + "isRead": { + "type": "boolean", + "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." + }, + "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." }, "changes": { "$ref": "#/$defs/ChangesSummary", @@ -2991,8 +2995,7 @@ }, "required": [ "resource", - "title", - "status" + "title" ] }, "ChangesSummary": { @@ -7848,7 +7851,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.\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`.", + "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 `IsRead` bit, the host MUST project that\nexact value into the matching `SessionChatSummary.isRead` field and publish\nthe updated compact chat catalog through `root/sessionSummaryChanged`.", "properties": { "type": { "const": "session/chatUpdated" @@ -9313,7 +9316,7 @@ }, "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.", + "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.isRead` projections.", "properties": { "type": { "const": "chat/isReadChanged" diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 7aa77125f..1af3e7b5e 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -1243,9 +1243,13 @@ "$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." }, - "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." + "isRead": { + "type": "boolean", + "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." + }, + "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." }, "changes": { "$ref": "#/$defs/ChangesSummary", @@ -1254,8 +1258,7 @@ }, "required": [ "resource", - "title", - "status" + "title" ] }, "ChangesSummary": { @@ -9012,7 +9015,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.\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`.", + "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 `IsRead` bit, the host MUST project that\nexact value into the matching `SessionChatSummary.isRead` field and publish\nthe updated compact chat catalog through `root/sessionSummaryChanged`.", "properties": { "type": { "const": "session/chatUpdated" @@ -10326,7 +10329,7 @@ }, "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.", + "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.isRead` projections.", "properties": { "type": { "const": "chat/isReadChanged" diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 689c986f6..91ebfc422 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -1421,9 +1421,13 @@ "$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." }, - "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." + "isRead": { + "type": "boolean", + "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." + }, + "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." }, "changes": { "$ref": "#/$defs/ChangesSummary", @@ -1432,8 +1436,7 @@ }, "required": [ "resource", - "title", - "status" + "title" ] }, "ChangesSummary": { diff --git a/schema/state.schema.json b/schema/state.schema.json index c47d89323..53c4931ab 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -1154,9 +1154,13 @@ "$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." }, - "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." + "isRead": { + "type": "boolean", + "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." + }, + "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." }, "changes": { "$ref": "#/$defs/ChangesSummary", @@ -1165,8 +1169,7 @@ }, "required": [ "resource", - "title", - "status" + "title" ] }, "ChangesSummary": { diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index 8102468b1..5f5d71058 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -845,7 +845,7 @@ export interface ChatDraftChangedAction { * 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. + * `SessionChatSummary.isRead` projections. * * @category Chat Actions * @version 1 diff --git a/types/channels-session/actions.ts b/types/channels-session/actions.ts index 9e54a3319..2cec8fa7f 100644 --- a/types/channels-session/actions.ts +++ b/types/channels-session/actions.ts @@ -81,8 +81,8 @@ 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 + * When `changes.status` changes the `IsRead` bit, the host MUST project that + * exact value into the matching `SessionChatSummary.isRead` field and publish * the updated compact chat catalog through `root/sessionSummaryChanged`. * * @category Session Actions diff --git a/types/channels-session/state.ts b/types/channels-session/state.ts index d556ba58a..bca0f014b 100644 --- a/types/channels-session/state.ts +++ b/types/channels-session/state.ts @@ -544,14 +544,23 @@ export interface SessionChatSummary { */ interactivity?: ChatInteractivity; /** - * Current chat status, matching {@link ChatSummary.status}. + * Exact read state for this chat. * - * 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. + * Generic clients use this to present read or unread chats in session lists + * without subscribing to the session or chat channel. `true` means read and + * `false` means unread. Absence means unknown for backward compatibility and + * MUST NOT be interpreted as read. */ - status: SessionStatus; + isRead?: boolean; + /** + * 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?: boolean; /** * Aggregate summary of file changes associated with this chat. * 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 69f149ca2..d460046f2 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,13 +14,11 @@ { "resource": "ahp-chat:/lead", "title": "Lead", - "status": 1, "interactivity": "read-only" }, { "resource": "ahp-chat:/worker", "title": "Worker", - "status": 1, "interactivity": "hidden" } ], @@ -38,13 +36,11 @@ { "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 026d45e4c..6e2d0372c 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 archived and read flags in the required status bitset so generic clients can group or filter archived chats without subscribing to the session channel.", + "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.", "type": "SessionSummary", "input": { "resource": "ahp-session:/s1", @@ -13,13 +13,12 @@ "chats": [ { "resource": "ahp-chat:/lead", - "title": "Lead", - "status": 33 + "title": "Lead" }, { "resource": "ahp-chat:/worker", "title": "Worker", - "status": 97 + "archived": true } ], "defaultChat": "ahp-chat:/lead" @@ -35,13 +34,12 @@ "chats": [ { "resource": "ahp-chat:/lead", - "title": "Lead", - "status": 33 + "title": "Lead" }, { "resource": "ahp-chat:/worker", "title": "Worker", - "status": 97 + "archived": true } ], "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 8cc8a59b9..c7b811240 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,13 +18,11 @@ "chats": [ { "resource": "ahp-chat:/lead", - "title": "Lead", - "status": 1 + "title": "Lead" }, { "resource": "ahp-chat:/worker", "title": "Worker", - "status": 1, "changes": { "additions": 12, "deletions": 3, @@ -50,13 +48,11 @@ "chats": [ { "resource": "ahp-chat:/lead", - "title": "Lead", - "status": 1 + "title": "Lead" }, { "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 index 994ab1554..d00038118 100644 --- 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 @@ -1,7 +1,7 @@ { "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 required status bitset.", + "description": "SessionSummary chat catalog entries preserve exact read and unread states while allowing older hosts to omit the unknown read state.", "type": "SessionSummary", "input": { "resource": "ahp-session:/s1", @@ -14,17 +14,16 @@ { "resource": "ahp-chat:/lead", "title": "Lead", - "status": 33 + "isRead": true }, { "resource": "ahp-chat:/worker", "title": "Worker", - "status": 24 + "isRead": false }, { - "resource": "ahp-chat:/future", - "title": "Future", - "status": 225 + "resource": "ahp-chat:/legacy", + "title": "Legacy" } ], "defaultChat": "ahp-chat:/lead" @@ -41,17 +40,16 @@ { "resource": "ahp-chat:/lead", "title": "Lead", - "status": 33 + "isRead": true }, { "resource": "ahp-chat:/worker", "title": "Worker", - "status": 24 + "isRead": false }, { - "resource": "ahp-chat:/future", - "title": "Future", - "status": 225 + "resource": "ahp-chat:/legacy", + "title": "Legacy" } ], "defaultChat": "ahp-chat:/lead" From b239010e4a7da5b0af3ff79a0f6d645d09fdc35c Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 2 Oct 2026 19:26:03 +0200 Subject: [PATCH 9/9] chat: consolidate compact state into optional status Replace unreleased catalog read and archive booleans with optional SessionStatus. Omitted status remains unknown so hosts can adopt the projection incrementally. Regenerate bindings and schemas, update projection docs, and cover omitted and combined flags in shared fixtures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generated/Actions.generated.cs | 6 ++-- .../Generated/State.generated.cs | 23 +++++---------- .../FixRegressionTests.cs | 8 ++--- clients/go/ahptypes/actions.generated.go | 6 ++-- clients/go/ahptypes/state.generated.go | 23 ++++++--------- .../generated/State.generated.kt | 23 +++++---------- clients/rust/crates/ahp-types/src/actions.rs | 6 ++-- clients/rust/crates/ahp-types/src/state.rs | 22 +++++--------- clients/rust/crates/ahp/src/hosts/runtime.rs | 15 ++++++---- .../Generated/State.generated.swift | 29 +++++++------------ clients/typescript/test/hosts.test.ts | 10 +++---- ...0260929-session-chat-summary-archived.json | 2 +- .../20261001-chat-read-state-action.json | 2 +- docs/guide/actions.md | 2 +- docs/guide/state-model.md | 15 +++++----- docs/specification/chat-channel.md | 2 +- docs/specification/session-channel.md | 12 ++++---- schema/actions.schema.json | 14 ++++----- schema/commands.schema.json | 14 ++++----- schema/errors.schema.json | 14 ++++----- schema/notifications.schema.json | 10 ++----- schema/state.schema.json | 10 ++----- types/channels-chat/actions.ts | 2 +- types/channels-session/actions.ts | 4 +-- types/channels-session/state.ts | 23 +++++---------- ...chat-summary-interactivity-round-trip.json | 4 +++ ...sion-chat-summary-archived-round-trip.json | 12 ++++---- ...ssion-chat-summary-changes-round-trip.json | 8 +++-- ...on-chat-summary-read-state-round-trip.json | 20 +++++++++---- 29 files changed, 155 insertions(+), 186 deletions(-) diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index 532568148..e8a2d26d4 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -1162,8 +1162,8 @@ public sealed record SessionChatRemovedAction /// SHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}. /// /// Mirrors the root-channel `root/sessionSummaryChanged` notification. -/// When `changes.status` changes the `IsRead` bit, the host MUST project that -/// exact value into the matching `SessionChatSummary.isRead` field and publish +/// 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 { @@ -2028,7 +2028,7 @@ public sealed record ChatDraftChangedAction /// 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.isRead` projections. +/// `SessionChatSummary.status` projections. public sealed record ChatIsReadChangedAction { public ActionType Type { get; init; } = ActionType.ChatIsReadChanged; diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index ee3b97315..5e116cf20 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -3168,23 +3168,16 @@ public sealed record SessionChatSummary [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ChatInteractivity? Interactivity { get; init; } - /// Exact read state for this chat. + /// Current chat status, matching {@link ChatSummary.status}. /// - /// Generic clients use this to present read or unread chats in session lists - /// without subscribing to the session or chat channel. `true` means read and - /// `false` means unread. Absence means unknown for backward compatibility and - /// MUST NOT be interpreted as read. + /// 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? IsRead { get; init; } - - /// 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. - [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/tests/AgentHostProtocol.Tests/FixRegressionTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs index 21438b990..a5fbe532d 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs @@ -311,7 +311,7 @@ public void ApplySummaryChange_Meta_OverridesWhenPresent_CarriesOverWhenAbsent() } [Fact] - public void ApplySummaryChange_Chats_ReplacesCompactReadProjection() + public void ApplySummaryChange_Chats_ReplacesCompactStatusProjection() { var entry = new HostEntry( new HostId("h"), @@ -334,7 +334,7 @@ public void ApplySummaryChange_Chats_ReplacesCompactReadProjection() { Resource = "ahp-chat:/default", Title = "Default", - IsRead = false, + Status = SessionStatus.Idle, }, ], }); @@ -347,13 +347,13 @@ public void ApplySummaryChange_Chats_ReplacesCompactReadProjection() { Resource = "ahp-chat:/default", Title = "Default", - IsRead = true, + Status = SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived, }, ], }); var summary = entry.Snapshot().SessionSummaries.Single(s => s.Resource == "ahp-session:/s1"); - Assert.True(Assert.Single(summary.Chats!).IsRead); + Assert.Equal(SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived, Assert.Single(summary.Chats!).Status); } // ── Upstream drift port (model config widened to JSON primitives; SessionModelInfo diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index a6ce87f41..986ba6ddf 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -212,8 +212,8 @@ type SessionChatRemovedAction struct { // SHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}. // // Mirrors the root-channel `root/sessionSummaryChanged` notification. -// When `changes.status` changes the `IsRead` bit, the host MUST project that -// exact value into the matching `SessionChatSummary.isRead` field and publish +// 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"` @@ -825,7 +825,7 @@ type ChatDraftChangedAction struct { // 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.isRead` projections. +// `SessionChatSummary.status` projections. type ChatIsReadChangedAction struct { Type ActionType `json:"type"` // Whether the chat has been read diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index e4a9d2f7c..92da47d03 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -1229,20 +1229,15 @@ type SessionChatSummary struct { // read-only chats. Absence defaults to {@link ChatInteractivity.Full} for // backward compatibility. Interactivity *ChatInteractivity `json:"interactivity,omitempty"` - // Exact read state for this chat. - // - // Generic clients use this to present read or unread chats in session lists - // without subscribing to the session or chat channel. `true` means read and - // `false` means unread. Absence means unknown for backward compatibility and - // MUST NOT be interpreted as read. - IsRead *bool `json:"isRead,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/generated/State.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt index 7cc06ae59..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,23 +2290,16 @@ data class SessionChatSummary( */ val interactivity: ChatInteractivity? = null, /** - * Exact read state for this chat. + * Current chat status, matching {@link ChatSummary.status}. * - * Generic clients use this to present read or unread chats in session lists - * without subscribing to the session or chat channel. `true` means read and - * `false` means unread. Absence means unknown for backward compatibility and - * MUST NOT be interpreted as read. + * 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 isRead: Boolean? = null, - /** - * 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. - */ - 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 0638c2465..7a28a266f 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -538,8 +538,8 @@ pub struct SessionChatRemovedAction { /// SHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}. /// /// Mirrors the root-channel `root/sessionSummaryChanged` notification. -/// When `changes.status` changes the `IsRead` bit, the host MUST project that -/// exact value into the matching `SessionChatSummary.isRead` field and publish +/// 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")] @@ -1490,7 +1490,7 @@ pub struct ChatDraftChangedAction { /// 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.isRead` projections. +/// `SessionChatSummary.status` projections. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatIsReadChangedAction { diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index c7131b9b5..05ce9e49f 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -2571,22 +2571,16 @@ pub struct SessionChatSummary { /// backward compatibility. #[serde(default, skip_serializing_if = "Option::is_none")] pub interactivity: Option, - /// Exact read state for this chat. + /// Current chat status, matching {@link ChatSummary.status}. /// - /// Generic clients use this to present read or unread chats in session lists - /// without subscribing to the session or chat channel. `true` means read and - /// `false` means unread. Absence means unknown for backward compatibility and - /// MUST NOT be interpreted as read. + /// 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 is_read: Option, - /// 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. - #[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 cafc89da0..976d0399a 100644 --- a/clients/rust/crates/ahp/src/hosts/runtime.rs +++ b/clients/rust/crates/ahp/src/hosts/runtime.rs @@ -776,7 +776,7 @@ mod tests { use serde_json::json; #[test] - fn summary_changes_replace_compact_chat_read_projection() { + fn summary_changes_replace_compact_chat_status_projection() { let mut summary: SessionSummary = serde_json::from_value(json!({ "resource": "ahp-session:/s1", "provider": "copilot", @@ -787,7 +787,7 @@ mod tests { "chats": [{ "resource": "ahp-chat:/default", "title": "Default", - "isRead": false + "status": 1 }] })) .expect("valid session summary"); @@ -795,7 +795,7 @@ mod tests { "chats": [{ "resource": "ahp-chat:/default", "title": "Default", - "isRead": true + "status": 97 }] })) .expect("valid session summary changes"); @@ -807,8 +807,13 @@ mod tests { .chats .as_ref() .and_then(|chats| chats.first()) - .and_then(|chat| chat.is_read), - Some(true) + .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/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 9894d683a..9b4c8d364 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -2439,20 +2439,15 @@ public struct SessionChatSummary: Codable, Sendable { /// read-only chats. Absence defaults to {@link ChatInteractivity.Full} for /// backward compatibility. public var interactivity: ChatInteractivity? - /// Exact read state for this chat. - /// - /// Generic clients use this to present read or unread chats in session lists - /// without subscribing to the session or chat channel. `true` means read and - /// `false` means unread. Absence means unknown for backward compatibility and - /// MUST NOT be interpreted as read. - public var isRead: Bool? - /// 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 @@ -2465,16 +2460,14 @@ public struct SessionChatSummary: Codable, Sendable { title: String, origin: ChatOrigin? = nil, interactivity: ChatInteractivity? = nil, - isRead: Bool? = nil, - archived: Bool? = nil, + status: SessionStatus? = nil, changes: ChangesSummary? = nil ) { self.resource = resource self.title = title self.origin = origin self.interactivity = interactivity - self.isRead = isRead - self.archived = archived + self.status = status self.changes = changes } } diff --git a/clients/typescript/test/hosts.test.ts b/clients/typescript/test/hosts.test.ts index f46fadb6b..fe8ffd2e3 100644 --- a/clients/typescript/test/hosts.test.ts +++ b/clients/typescript/test/hosts.test.ts @@ -616,10 +616,10 @@ test('aggregatedSessions sorts by modifiedAt descending and tags hostLabel', asy } }); -test('sessionSummaryChanged replaces the compact chat read projection', async () => { +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', isRead: false }, + { resource: 'ahp-chat:/default', title: 'Default', status: SessionStatus.Idle }, ]; const state: FakeHostState = makeFakeState({ sessions: [initial], @@ -633,7 +633,7 @@ test('sessionSummaryChanged replaces the compact chat read projection', async () session: initial.resource, changes: { chats: [ - { resource: 'ahp-chat:/default', title: 'Default', isRead: true }, + { resource: 'ahp-chat:/default', title: 'Default', status: SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived }, ], }, }, @@ -654,11 +654,11 @@ test('sessionSummaryChanged replaces the compact chat read projection', async () transportFactory: makeBasicFactory(state), }); await waitUntil(() => - multi.aggregatedSessions()[0]?.summary.chats?.[0]?.isRead === true + 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', isRead: true }, + { resource: 'ahp-chat:/default', title: 'Default', status: SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived }, ]); } finally { await multi.shutdown(); 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 index 5a969cb88..f03094090 100644 --- a/docs/.changes/20261001-chat-read-state-action.json +++ b/docs/.changes/20261001-chat-read-state-action.json @@ -1,4 +1,4 @@ { "type": "added", - "message": "`chat/isReadChanged` action and `SessionChatSummary.isRead` projection for independently tracking the exact read state of any known chat, including the default chat." + "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 09d4bdcb4..2b793756a 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -215,7 +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.isRead` projection | +| `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 6f0c54976..2d698d4a7 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -112,7 +112,7 @@ SessionSummary { workingDirectories?: URI[] // equal-peer working directories annotations?: AnnotationsSummary changes?: ChangesSummary - chats?: SessionChatSummary[] // compact list presentation, including exact per-chat read state when known + chats?: SessionChatSummary[] // compact list presentation, including per-chat status } ProjectInfo { @@ -121,12 +121,13 @@ ProjectInfo { } ``` -`SessionChatSummary.isRead` is optional for backward compatibility. `true` -means the chat is read, `false` means it is unread, and absence means unknown; -clients must not interpret an absent value as read. Hosts update this compact -projection alongside `ChatSummary.status` when `chat/isReadChanged` is -accepted, so session lists can restore exact per-chat read state without -subscribing to every session or chat. +`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. diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index a40e8be3c..469908f52 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -30,7 +30,7 @@ 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.isRead` projection synchronized through +matching compact `SessionChatSummary.status` projection synchronized through `root/sessionSummaryChanged`. `session/isReadChanged` independently changes the owning session's read state; neither action implies the other. diff --git a/docs/specification/session-channel.md b/docs/specification/session-channel.md index 36a960838..2631b22b1 100644 --- a/docs/specification/session-channel.md +++ b/docs/specification/session-channel.md @@ -65,13 +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 `SessionStatus.IsRead` bit changes, the producer MUST project its +When a chat's `status` changes, the producer MUST project its exact value into the matching -[`SessionChatSummary.isRead`](/reference/session#sessionchatsummary) field and +[`SessionChatSummary.status`](/reference/session#sessionchatsummary) field and publish the complete compact `SessionSummary.chats` catalog through -`root/sessionSummaryChanged`. `true` means read and `false` means unread. -Absence means the host did not provide the projection and clients MUST treat -the state as unknown, not as read. +`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 4dc7e1cb9..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.\nWhen `changes.status` changes the `IsRead` bit, the host MUST project that\nexact value into the matching `SessionChatSummary.isRead` field and publish\nthe updated compact chat catalog through `root/sessionSummaryChanged`.", + "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" @@ -1657,7 +1657,7 @@ }, "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.isRead` projections.", + "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" @@ -3765,13 +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." }, - "isRead": { - "type": "boolean", - "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." - }, - "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/commands.schema.json b/schema/commands.schema.json index a6c6d0295..907789a41 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -2980,13 +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." }, - "isRead": { - "type": "boolean", - "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." - }, - "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", @@ -7851,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.\nWhen `changes.status` changes the `IsRead` bit, the host MUST project that\nexact value into the matching `SessionChatSummary.isRead` field and publish\nthe updated compact chat catalog through `root/sessionSummaryChanged`.", + "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" @@ -9316,7 +9312,7 @@ }, "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.isRead` projections.", + "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" diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 1af3e7b5e..26373f3b0 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -1243,13 +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." }, - "isRead": { - "type": "boolean", - "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." - }, - "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", @@ -9015,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.\nWhen `changes.status` changes the `IsRead` bit, the host MUST project that\nexact value into the matching `SessionChatSummary.isRead` field and publish\nthe updated compact chat catalog through `root/sessionSummaryChanged`.", + "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" @@ -10329,7 +10325,7 @@ }, "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.isRead` projections.", + "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" diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 91ebfc422..e5685d233 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -1421,13 +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." }, - "isRead": { - "type": "boolean", - "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." - }, - "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 53c4931ab..058febd69 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -1154,13 +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." }, - "isRead": { - "type": "boolean", - "description": "Exact read state for this chat.\n\nGeneric clients use this to present read or unread chats in session lists\nwithout subscribing to the session or chat channel. `true` means read and\n`false` means unread. Absence means unknown for backward compatibility and\nMUST NOT be interpreted as read." - }, - "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/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index 5f5d71058..8102468b1 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -845,7 +845,7 @@ export interface ChatDraftChangedAction { * 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.isRead` projections. + * `SessionChatSummary.status` projections. * * @category Chat Actions * @version 1 diff --git a/types/channels-session/actions.ts b/types/channels-session/actions.ts index 2cec8fa7f..9e54a3319 100644 --- a/types/channels-session/actions.ts +++ b/types/channels-session/actions.ts @@ -81,8 +81,8 @@ export interface SessionChatRemovedAction { * SHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}. * * Mirrors the root-channel `root/sessionSummaryChanged` notification. - * When `changes.status` changes the `IsRead` bit, the host MUST project that - * exact value into the matching `SessionChatSummary.isRead` field and publish + * 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 diff --git a/types/channels-session/state.ts b/types/channels-session/state.ts index bca0f014b..d2e964118 100644 --- a/types/channels-session/state.ts +++ b/types/channels-session/state.ts @@ -544,23 +544,16 @@ export interface SessionChatSummary { */ interactivity?: ChatInteractivity; /** - * Exact read state for this chat. + * Current chat status, matching {@link ChatSummary.status}. * - * Generic clients use this to present read or unread chats in session lists - * without subscribing to the session or chat channel. `true` means read and - * `false` means unread. Absence means unknown for backward compatibility and - * MUST NOT be interpreted as read. + * 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. */ - isRead?: boolean; - /** - * 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?: boolean; + status?: SessionStatus; /** * Aggregate summary of file changes associated with this chat. * 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 index d00038118..9aaf6c2ac 100644 --- 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 @@ -1,7 +1,7 @@ { "name": "session-chat-summary-read-state-round-trip", "group": "A", - "description": "SessionSummary chat catalog entries preserve exact read and unread states while allowing older hosts to omit the unknown read state.", + "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", @@ -14,12 +14,17 @@ { "resource": "ahp-chat:/lead", "title": "Lead", - "isRead": true + "status": 33 }, { "resource": "ahp-chat:/worker", "title": "Worker", - "isRead": false + "status": 24 + }, + { + "resource": "ahp-chat:/future", + "title": "Future", + "status": 225 }, { "resource": "ahp-chat:/legacy", @@ -40,12 +45,17 @@ { "resource": "ahp-chat:/lead", "title": "Lead", - "isRead": true + "status": 33 }, { "resource": "ahp-chat:/worker", "title": "Worker", - "isRead": false + "status": 24 + }, + { + "resource": "ahp-chat:/future", + "title": "Future", + "status": 225 }, { "resource": "ahp-chat:/legacy",