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