Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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.</summary>
/// 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`.</summary>
public sealed record SessionChatUpdatedAction
{
public ActionType Type { get; init; } = ActionType.SessionChatUpdated;
Expand Down Expand Up @@ -2015,6 +2020,23 @@ public sealed record ChatDraftChangedAction
public Message? Draft { get; init; }
}

/// <summary>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.</summary>
public sealed record ChatIsReadChangedAction
{
public ActionType Type { get; init; } = ActionType.ChatIsReadChanged;

/// <summary>Whether the chat has been read</summary>
public bool IsRead { get; init; }
}

/// <summary>The archived state of the chat changed.
///
/// Dispatched by a client to archive a chat independently of its owning
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ public sealed record SessionSummaryChangedParams
/// <summary>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.</summary>
/// MUST be omitted by senders; receivers SHOULD ignore them if present.
/// When `chats` is present, it replaces the complete compact chat catalog.</summary>
public required PartialSessionSummary Changes { get; init; }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3168,14 +3168,16 @@ public sealed record SessionChatSummary
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ChatInteractivity? Interactivity { get; init; }

/// <summary>Whether this chat has been archived independently of its owning session
/// (see `chat/isArchivedChanged`).
/// <summary>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.</summary>
/// 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.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Archived { get; init; }
public SessionStatus? Status { get; init; }

/// <summary>Aggregate summary of file changes associated with this chat.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
}
Expand Down
4 changes: 4 additions & 0 deletions clients/dotnet/src/AgentHostProtocol/Reducers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2566,6 +2569,7 @@ public static ReduceOutcome ApplyToAutomationRun(
"chat/pendingMessageRemoved",
"chat/queuedMessagesReordered",
"chat/draftChanged",
"chat/isReadChanged",
"chat/isArchivedChanged",
"chat/inputAnswerChanged",
"chat/inputCompleted",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions clients/go/ahp/reducers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions clients/go/ahptypes/actions.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {}
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions clients/go/ahptypes/notifications.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down
16 changes: 9 additions & 7 deletions clients/go/ahptypes/state.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1831,6 +1842,7 @@ internal object StateActionSerializer : KSerializer<StateAction> {
"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))
Expand Down Expand Up @@ -1942,6 +1954,7 @@ internal object StateActionSerializer : KSerializer<StateAction> {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
Loading
Loading