diff --git a/CHANGELOG.md b/CHANGELOG.md index e6c655a9..06b5ebb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,17 @@ changes accumulate. Track in-flight protocol changes via PRs touching extends `PaginatedResult`, letting clients fetch a large session catalogue incrementally. Fully additive — omitting the fields preserves today's behaviour. +- `SubscribeParams.view.turns`, `ChatState.turnsNextCursor`, and the + `chat/turnsLoaded` action so clients can subscribe to a bounded tail of chat + history and page older turns into the reduced chat state on demand. + +### Changed + +- `fetchTurns` now accepts `cursor` from `ChatState.turnsNextCursor` and returns + an empty result after the host has loaded older turns into chat state, instead + of returning a detached `{ turns, hasMore }` page. +- Generated clients now advertise only protocol `0.5.1`, since the `fetchTurns` + contract is not wire-compatible with `0.5.0`. ### Removed diff --git a/clients/go/CHANGELOG.md b/clients/go/CHANGELOG.md index 75dcfeab..9ed5399c 100644 --- a/clients/go/CHANGELOG.md +++ b/clients/go/CHANGELOG.md @@ -28,6 +28,9 @@ tag whose matching `## [X.Y.Z]` heading is missing from this file. `ListSessionsParams` and `ListSessionsResult` now carry these fields, letting clients page through a large session catalogue. Fully additive — omitting the fields preserves prior behaviour. +- `SubscribeParams.View.Turns`, `ChatState.TurnsNextCursor`, and the + `chat/turnsLoaded` action so clients can subscribe to a bounded tail of chat + history and page older turns into the reduced chat state on demand. - `SessionState.InputNeeded` — a session-level aggregate of outstanding input requests across all chats (`SessionInputRequest` union with `SessionChatInputRequest`, `SessionToolConfirmationRequest`, and @@ -42,6 +45,14 @@ tag whose matching `## [X.Y.Z]` heading is missing from this file. - Optional `Model` and `Tools` fields on `AgentCustomization` for a custom agent's pinned model and tool allowlist. +### Changed + +- `fetchTurns` now accepts `Cursor` from `ChatState.TurnsNextCursor` and returns + an empty result after the host has loaded older turns into chat state, instead + of returning a detached `{ turns, hasMore }` page. +- Generated clients now advertise only protocol `0.5.1`, since the `fetchTurns` + contract is not wire-compatible with `0.5.0`. + ### Removed - `Filter` field from `ListSessionsParams`. It was an untyped placeholder with diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 45b4fc92..6badfc05 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -512,6 +512,20 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R }) case *ahptypes.ChatTruncatedAction: return applyTruncated(state, a.TurnId) + case *ahptypes.ChatTurnsLoadedAction: + existingIds := make(map[string]struct{}, len(state.Turns)) + for _, turn := range state.Turns { + existingIds[turn.Id] = struct{}{} + } + olderTurns := make([]ahptypes.Turn, 0, len(a.Turns)+len(state.Turns)) + for _, turn := range a.Turns { + if _, ok := existingIds[turn.Id]; !ok { + olderTurns = append(olderTurns, turn) + } + } + state.Turns = append(olderTurns, state.Turns...) + state.TurnsNextCursor = a.TurnsNextCursor + return ReduceOutcomeApplied case *ahptypes.ChatInputRequestedAction: upsertInputRequest(state, a.Request) return ReduceOutcomeApplied @@ -1173,6 +1187,7 @@ func applyMcpServerStatusChanged(state *ahptypes.SessionState, a *ahptypes.Sessi func applyTruncated(state *ahptypes.ChatState, turnID *string) ReduceOutcome { if turnID == nil { state.Turns = []ahptypes.Turn{} + state.TurnsNextCursor = nil } else { idx := -1 for i := range state.Turns { diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index fd7b7d08..14c6382d 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -62,6 +62,7 @@ const ( ActionTypeSessionCustomizationRemoved ActionType = "session/customizationRemoved" ActionTypeSessionMcpServerStateChanged ActionType = "session/mcpServerStateChanged" ActionTypeChatTruncated ActionType = "chat/truncated" + ActionTypeChatTurnsLoaded ActionType = "chat/turnsLoaded" ActionTypeSessionIsReadChanged ActionType = "session/isReadChanged" ActionTypeSessionIsArchivedChanged ActionType = "session/isArchivedChanged" ActionTypeSessionActivityChanged ActionType = "session/activityChanged" @@ -673,6 +674,21 @@ type ChatTruncatedAction struct { TurnId *string `json:"turnId,omitempty"` } +// Loads older completed turns into this chat's state. +// +// Hosts dispatch this before responding to `fetchTurns`, and before applying +// any operation that references a turn older than the currently loaded window. +// `turns` is ordered oldest-first and is prepended to the current `turns` +// window. `turnsNextCursor` replaces the state's cursor; omit it when all +// retained turns are now loaded. +type ChatTurnsLoadedAction struct { + Type ActionType `json:"type"` + // Older completed turns loaded into the state, ordered oldest-first. + Turns []Turn `json:"turns"` + // Opaque cursor for loading the next older page, if one remains. + TurnsNextCursor *string `json:"turnsNextCursor,omitempty"` +} + // The read state of the session changed. // // Dispatched by a client to mark a session as read (e.g. after viewing it) @@ -1278,6 +1294,7 @@ func (*ChatInputRequestedAction) isStateAction() {} func (*ChatInputAnswerChangedAction) isStateAction() {} func (*ChatInputCompletedAction) isStateAction() {} func (*ChatTruncatedAction) isStateAction() {} +func (*ChatTurnsLoadedAction) isStateAction() {} func (*SessionIsReadChangedAction) isStateAction() {} func (*SessionIsArchivedChangedAction) isStateAction() {} func (*SessionActivityChangedAction) isStateAction() {} @@ -1538,6 +1555,12 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "chat/turnsLoaded": + var value ChatTurnsLoadedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value case "session/isReadChanged": var value SessionIsReadChangedAction if err := json.Unmarshal(data, &value); err != nil { diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index c7b03f49..f8c29d8a 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -206,6 +206,24 @@ type SubscribeParams struct { // updates while preserving the same reduced state. Omit this field for the // server's default delivery behavior. Delivery *SubscriptionDeliveryOptions `json:"delivery,omitempty"` + // Optional client-requested shape for the returned snapshot. + // + // Servers that do not understand a requested view ignore it and return their + // default snapshot. Clients MUST tolerate receiving more state than requested. + View *SubscribeView `json:"view,omitempty"` +} + +// Optional client-requested shape for a subscription snapshot. +type SubscribeView struct { + // Advisory number of most-recent completed turns to expose in a chat + // snapshot. + // + // Servers MAY return more or fewer turns than requested. When omitted, the + // host MUST return all retained turns. When older turns remain available, the + // returned {@link ChatState} carries `turnsNextCursor`; clients pass that + // cursor to `fetchTurns` to ask the host to page more turns into the chat + // state. + Turns *int64 `json:"turns,omitempty"` } // Advisory delivery preferences for a single subscription. @@ -683,23 +701,29 @@ type CreateResourceWatchResult struct { Channel URI `json:"channel"` } -// Fetches historical turns for a chat. Used for lazy loading of conversation -// history. +// Requests that the host load older historical turns into a chat state. +// +// The command result does not carry turns. Instead, before responding, the host +// MUST dispatch `chat/turnsLoaded` to insert any loaded turns into the chat +// channel's `turns` state, ahead of the already-loaded window, and update or +// clear `turnsNextCursor`. +// +// Before applying any operation that references a turn outside the currently +// loaded window, the host MUST eagerly load enough older turns into state for +// that operation to reduce against valid state. type FetchTurnsParams struct { // Channel URI this command targets. Channel URI `json:"channel"` - // Turn ID to fetch before (exclusive). Omit to fetch from the most recent turn. - Before *string `json:"before,omitempty"` - // Maximum number of turns to return. Server MAY impose its own upper bound. - Limit *int64 `json:"limit,omitempty"` + // Opaque cursor from `ChatState.turnsNextCursor`. + // + // The host MUST reject unrecognised cursors with `InvalidParams`. Omit only + // when asking the host to opportunistically load its next older page for the + // chat, if any. + Cursor *string `json:"cursor,omitempty"` } // Result of the `fetchTurns` command. type FetchTurnsResult struct { - // The requested turns, ordered oldest-first - Turns []Turn `json:"turns"` - // Whether more turns exist before the returned range - HasMore bool `json:"hasMore"` } // Stop receiving updates for a channel. diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 33278b92..1f7e5659 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -1002,6 +1002,13 @@ type ChatState struct { WorkingDirectory *URI `json:"workingDirectory,omitempty"` // Completed turns Turns []Turn `json:"turns"` + // Cursor for loading older completed turns into this chat state. + // + // Presence means `turns` is a tail window and more historical turns are + // available. Pass this opaque cursor to `fetchTurns`; the host MUST insert + // the loaded turns into state and update or clear this cursor before + // responding. Absence means the state contains all retained turns. + TurnsNextCursor *string `json:"turnsNextCursor,omitempty"` // Currently in-progress turn ActiveTurn *ActiveTurn `json:"activeTurn,omitempty"` // Message to inject into the current turn at a convenient point diff --git a/clients/go/ahptypes/version.generated.go b/clients/go/ahptypes/version.generated.go index 8d270be4..46b7bed7 100644 --- a/clients/go/ahptypes/version.generated.go +++ b/clients/go/ahptypes/version.generated.go @@ -13,7 +13,6 @@ const ProtocolVersion = "0.5.1" // shared backing array. var supportedProtocolVersions = []string{ "0.5.1", - "0.5.0", } // SupportedProtocolVersions returns every protocol version this client diff --git a/clients/go/release-metadata.json b/clients/go/release-metadata.json index c8eed75a..9f92d042 100644 --- a/clients/go/release-metadata.json +++ b/clients/go/release-metadata.json @@ -2,7 +2,6 @@ "client": "go", "packageVersion": "0.5.0", "supportedProtocolVersions": [ - "0.5.1", - "0.5.0" + "0.5.1" ] } diff --git a/clients/kotlin/CHANGELOG.md b/clients/kotlin/CHANGELOG.md index 2a1a66b8..cc7dcc1d 100644 --- a/clients/kotlin/CHANGELOG.md +++ b/clients/kotlin/CHANGELOG.md @@ -28,6 +28,9 @@ versions (`*-SNAPSHOT`) are explicitly rejected by the publish pipeline; bump `ListSessionsParams` and `ListSessionsResult` now carry these fields, letting clients page through a large session catalogue. Fully additive — omitting the fields preserves prior behaviour. +- `SubscribeParams.view.turns`, `ChatState.turnsNextCursor`, and the + `chat/turnsLoaded` action so clients can subscribe to a bounded tail of chat + history and page older turns into the reduced chat state on demand. - `SessionState.inputNeeded` — a session-level aggregate of outstanding input requests across all chats (`SessionInputRequest` sealed interface with `SessionChatInputRequest`, `SessionToolConfirmationRequest`, and @@ -41,6 +44,14 @@ versions (`*-SNAPSHOT`) are explicitly rejected by the publish pipeline; bump - Optional `model` and `tools` fields on `AgentCustomization` for a custom agent's pinned model and tool allowlist. +### Changed + +- `fetchTurns` now accepts `cursor` from `ChatState.turnsNextCursor` and returns + an empty result after the host has loaded older turns into chat state, instead + of returning a detached `{ turns, hasMore }` page. +- Generated clients now advertise only protocol `0.5.1`, since the `fetchTurns` + contract is not wire-compatible with `0.5.0`. + ### Removed - `filter` field from `ListSessionsParams`. It was an untyped placeholder with diff --git a/clients/kotlin/release-metadata.json b/clients/kotlin/release-metadata.json index d9e0f413..4b357323 100644 --- a/clients/kotlin/release-metadata.json +++ b/clients/kotlin/release-metadata.json @@ -2,7 +2,6 @@ "client": "kotlin", "packageVersion": "0.5.0", "supportedProtocolVersions": [ - "0.5.1", - "0.5.0" + "0.5.1" ] } 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 85b5ccc1..16bcad54 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -1054,6 +1054,7 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when } val next = state.copy( turns = turns, + turnsNextCursor = if (a.turnId == null) null else state.turnsNextCursor, activeTurn = null, inputRequests = null, modifiedAt = nowIsoString(), @@ -1061,6 +1062,16 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when next.copy(status = chatSummaryStatus(next)) } + is StateActionChatTurnsLoaded -> { + val a = action.value + val existingIds = state.turns.map { it.id }.toSet() + val olderTurns = a.turns.filter { it.id !in existingIds } + state.copy( + turns = olderTurns + state.turns, + turnsNextCursor = a.turnsNextCursor, + ) + } + // ── Session Input Requests ──────────────────────────────────────────── is StateActionChatInputRequested -> 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 0bfff3a8..e503242d 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 @@ -112,6 +112,8 @@ enum class ActionType { SESSION_MCP_SERVER_STATE_CHANGED, @SerialName("chat/truncated") CHAT_TRUNCATED, + @SerialName("chat/turnsLoaded") + CHAT_TURNS_LOADED, @SerialName("session/isReadChanged") SESSION_IS_READ_CHANGED, @SerialName("session/isArchivedChanged") @@ -968,6 +970,19 @@ data class ChatTruncatedAction( val turnId: String? = null ) +@Serializable +data class ChatTurnsLoadedAction( + val type: ActionType, + /** + * Older completed turns loaded into the state, ordered oldest-first. + */ + val turns: List, + /** + * Opaque cursor for loading the next older page, if one remains. + */ + val turnsNextCursor: String? = null +) + @Serializable data class SessionConfigChangedAction( val type: ActionType, @@ -1398,6 +1413,7 @@ sealed interface StateAction @JvmInline value class StateActionSessionCustomizationRemoved(val value: SessionCustomizationRemovedAction) : StateAction @JvmInline value class StateActionSessionMcpServerStateChanged(val value: SessionMcpServerStateChangedAction) : StateAction @JvmInline value class StateActionChatTruncated(val value: ChatTruncatedAction) : StateAction +@JvmInline value class StateActionChatTurnsLoaded(val value: ChatTurnsLoadedAction) : StateAction @JvmInline value class StateActionSessionConfigChanged(val value: SessionConfigChangedAction) : StateAction @JvmInline value class StateActionSessionMetaChanged(val value: SessionMetaChangedAction) : StateAction @JvmInline value class StateActionChangesetStatusChanged(val value: ChangesetStatusChangedAction) : StateAction @@ -1488,6 +1504,7 @@ internal object StateActionSerializer : KSerializer { "session/customizationRemoved" -> StateActionSessionCustomizationRemoved(input.json.decodeFromJsonElement(SessionCustomizationRemovedAction.serializer(), element)) "session/mcpServerStateChanged" -> StateActionSessionMcpServerStateChanged(input.json.decodeFromJsonElement(SessionMcpServerStateChangedAction.serializer(), element)) "chat/truncated" -> StateActionChatTruncated(input.json.decodeFromJsonElement(ChatTruncatedAction.serializer(), element)) + "chat/turnsLoaded" -> StateActionChatTurnsLoaded(input.json.decodeFromJsonElement(ChatTurnsLoadedAction.serializer(), element)) "session/configChanged" -> StateActionSessionConfigChanged(input.json.decodeFromJsonElement(SessionConfigChangedAction.serializer(), element)) "session/metaChanged" -> StateActionSessionMetaChanged(input.json.decodeFromJsonElement(SessionMetaChangedAction.serializer(), element)) "changeset/statusChanged" -> StateActionChangesetStatusChanged(input.json.decodeFromJsonElement(ChangesetStatusChangedAction.serializer(), element)) @@ -1571,6 +1588,7 @@ internal object StateActionSerializer : KSerializer { is StateActionSessionCustomizationRemoved -> output.json.encodeToJsonElement(SessionCustomizationRemovedAction.serializer(), value.value) is StateActionSessionMcpServerStateChanged -> output.json.encodeToJsonElement(SessionMcpServerStateChangedAction.serializer(), value.value) is StateActionChatTruncated -> output.json.encodeToJsonElement(ChatTruncatedAction.serializer(), value.value) + is StateActionChatTurnsLoaded -> output.json.encodeToJsonElement(ChatTurnsLoadedAction.serializer(), value.value) is StateActionSessionConfigChanged -> output.json.encodeToJsonElement(SessionConfigChangedAction.serializer(), value.value) is StateActionSessionMetaChanged -> output.json.encodeToJsonElement(SessionMetaChangedAction.serializer(), value.value) is StateActionChangesetStatusChanged -> output.json.encodeToJsonElement(ChangesetStatusChangedAction.serializer(), value.value) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt index 26402250..1dc2c60b 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt @@ -262,7 +262,29 @@ data class SubscribeParams( * updates while preserving the same reduced state. Omit this field for the * server's default delivery behavior. */ - val delivery: SubscriptionDeliveryOptions? = null + val delivery: SubscriptionDeliveryOptions? = null, + /** + * Optional client-requested shape for the returned snapshot. + * + * Servers that do not understand a requested view ignore it and return their + * default snapshot. Clients MUST tolerate receiving more state than requested. + */ + val view: SubscribeView? = null +) + +@Serializable +data class SubscribeView( + /** + * Advisory number of most-recent completed turns to expose in a chat + * snapshot. + * + * Servers MAY return more or fewer turns than requested. When omitted, the + * host MUST return all retained turns. When older turns remain available, the + * returned {@link ChatState} carries `turnsNextCursor`; clients pass that + * cursor to `fetchTurns` to ask the host to page more turns into the chat + * state. + */ + val turns: Long? = null ) @Serializable @@ -755,26 +777,17 @@ data class FetchTurnsParams( */ val channel: String, /** - * Turn ID to fetch before (exclusive). Omit to fetch from the most recent turn. - */ - val before: String? = null, - /** - * Maximum number of turns to return. Server MAY impose its own upper bound. + * Opaque cursor from `ChatState.turnsNextCursor`. + * + * The host MUST reject unrecognised cursors with `InvalidParams`. Omit only + * when asking the host to opportunistically load its next older page for the + * chat, if any. */ - val limit: Long? = null + val cursor: String? = null ) @Serializable -data class FetchTurnsResult( - /** - * The requested turns, ordered oldest-first - */ - val turns: List, - /** - * Whether more turns exist before the returned range - */ - val hasMore: Boolean -) +class FetchTurnsResult @Serializable data class UnsubscribeParams( 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 243ef5e3..baeee461 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 @@ -1129,6 +1129,15 @@ data class ChatState( * Completed turns */ val turns: List, + /** + * Cursor for loading older completed turns into this chat state. + * + * Presence means `turns` is a tail window and more historical turns are + * available. Pass this opaque cursor to `fetchTurns`; the host MUST insert + * the loaded turns into state and update or clear this cursor before + * responding. Absence means the state contains all retained turns. + */ + val turnsNextCursor: String? = null, /** * Currently in-progress turn */ diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt index c1a4c3cf..90d1d292 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt @@ -17,5 +17,4 @@ public const val PROTOCOL_VERSION: String = "0.5.1" */ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( "0.5.1", - "0.5.0", ) diff --git a/clients/rust/CHANGELOG.md b/clients/rust/CHANGELOG.md index d24b55d9..363ce95f 100644 --- a/clients/rust/CHANGELOG.md +++ b/clients/rust/CHANGELOG.md @@ -29,6 +29,10 @@ matching `## [X.Y.Z]` heading is missing from this file. `ListSessionsParams` and `ListSessionsResult` now carry these fields, letting clients page through a large session catalogue. Fully additive — omitting the fields preserves prior behaviour. +- `SubscribeParams.view.turns`, `Client::subscribe_with_options`, + `ChatState.turns_next_cursor`, and the `chat/turnsLoaded` action so clients + can subscribe to a bounded tail of chat history and page older turns into the + reduced chat state on demand. - `SessionState.input_needed` — a session-level aggregate of outstanding input requests across all chats (`SessionInputRequest` enum with `SessionChatInputRequest`, `SessionToolConfirmationRequest`, and @@ -44,8 +48,14 @@ matching `## [X.Y.Z]` heading is missing from this file. ### Changed +- `fetchTurns` now accepts `cursor` from `ChatState.turns_next_cursor` and + returns an empty result after the host has loaded older turns into chat state, + instead of returning a detached `{ turns, hasMore }` page. +- Generated clients now advertise only protocol `0.5.1`, since the `fetchTurns` + contract is not wire-compatible with `0.5.0`. + - Direct Rust struct literals for `SubscribeParams` must now include - `delivery: None`; use `SubscribeParams::new(channel)` or + `delivery: None` and `view: None`; use `SubscribeParams::new(channel)` or `Client::subscribe` to keep the default delivery behavior. ### Removed diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 2e79251e..5aaf0357 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -19,7 +19,7 @@ use crate::state::{ ConfirmationOption, Customization, ErrorInfo, McpServerState, Message, ModelSelection, PendingMessageKind, ResponsePart, SessionActiveClient, SessionInputRequest, TerminalClaim, TerminalInfo, TextRange, ToolCallCancellationReason, ToolCallConfirmationReason, - ToolCallContributor, ToolCallResult, ToolDefinition, ToolResultContent, UsageInfo, + ToolCallContributor, ToolCallResult, ToolDefinition, ToolResultContent, Turn, UsageInfo, }; // ─── ActionType ────────────────────────────────────────────────────── @@ -113,6 +113,8 @@ pub enum ActionType { SessionMcpServerStateChanged, #[serde(rename = "chat/truncated")] ChatTruncated, + #[serde(rename = "chat/turnsLoaded")] + ChatTurnsLoaded, #[serde(rename = "session/isReadChanged")] SessionIsReadChanged, #[serde(rename = "session/isArchivedChanged")] @@ -1069,6 +1071,23 @@ pub struct ChatTruncatedAction { pub turn_id: Option, } +/// Loads older completed turns into this chat's state. +/// +/// Hosts dispatch this before responding to `fetchTurns`, and before applying +/// any operation that references a turn older than the currently loaded window. +/// `turns` is ordered oldest-first and is prepended to the current `turns` +/// window. `turnsNextCursor` replaces the state's cursor; omit it when all +/// retained turns are now loaded. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatTurnsLoadedAction { + /// Older completed turns loaded into the state, ordered oldest-first. + pub turns: Vec, + /// Opaque cursor for loading the next older page, if one remains. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turns_next_cursor: Option, +} + /// Client changed a mutable config value mid-session. /// /// Only properties with `sessionMutable: true` in the config schema may be @@ -1603,6 +1622,8 @@ pub enum StateAction { SessionMcpServerStateChanged(Box), #[serde(rename = "chat/truncated")] ChatTruncated(ChatTruncatedAction), + #[serde(rename = "chat/turnsLoaded")] + ChatTurnsLoaded(ChatTurnsLoadedAction), #[serde(rename = "session/configChanged")] SessionConfigChanged(SessionConfigChangedAction), #[serde(rename = "session/metaChanged")] diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index b55dd88f..b84a6c08 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -241,6 +241,12 @@ pub struct SubscribeParams { /// server's default delivery behavior. #[serde(default, skip_serializing_if = "Option::is_none")] pub delivery: Option, + /// Optional client-requested shape for the returned snapshot. + /// + /// Servers that do not understand a requested view ignore it and return their + /// default snapshot. Clients MUST tolerate receiving more state than requested. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub view: Option, } impl SubscribeParams { @@ -249,6 +255,7 @@ impl SubscribeParams { Self { channel: channel.into(), delivery: None, + view: None, } } @@ -257,10 +264,36 @@ impl SubscribeParams { Self { channel: channel.into(), delivery: Some(delivery), + view: None, + } + } + + /// Create subscribe params with snapshot-shaping preferences. + pub fn with_view(channel: impl Into, view: SubscribeView) -> Self { + Self { + channel: channel.into(), + delivery: None, + view: Some(view), } } } +/// Optional client-requested shape for a subscription snapshot. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SubscribeView { + /// Advisory number of most-recent completed turns to expose in a chat + /// snapshot. + /// + /// Servers MAY return more or fewer turns than requested. When omitted, the + /// host MUST return all retained turns. When older turns remain available, the + /// returned {@link ChatState} carries `turnsNextCursor`; clients pass that + /// cursor to `fetchTurns` to ask the host to page more turns into the chat + /// state. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turns: Option, +} + /// Advisory delivery preferences for a single subscription. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -826,30 +859,34 @@ pub struct CreateResourceWatchResult { pub channel: Uri, } -/// Fetches historical turns for a chat. Used for lazy loading of conversation -/// history. +/// Requests that the host load older historical turns into a chat state. +/// +/// The command result does not carry turns. Instead, before responding, the host +/// MUST dispatch `chat/turnsLoaded` to insert any loaded turns into the chat +/// channel's `turns` state, ahead of the already-loaded window, and update or +/// clear `turnsNextCursor`. +/// +/// Before applying any operation that references a turn outside the currently +/// loaded window, the host MUST eagerly load enough older turns into state for +/// that operation to reduce against valid state. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FetchTurnsParams { /// Channel URI this command targets. pub channel: Uri, - /// Turn ID to fetch before (exclusive). Omit to fetch from the most recent turn. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub before: Option, - /// Maximum number of turns to return. Server MAY impose its own upper bound. + /// Opaque cursor from `ChatState.turnsNextCursor`. + /// + /// The host MUST reject unrecognised cursors with `InvalidParams`. Omit only + /// when asking the host to opportunistically load its next older page for the + /// chat, if any. #[serde(default, skip_serializing_if = "Option::is_none")] - pub limit: Option, + pub cursor: Option, } /// Result of the `fetchTurns` command. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct FetchTurnsResult { - /// The requested turns, ordered oldest-first - pub turns: Vec, - /// Whether more turns exist before the returned range - pub has_more: bool, -} +pub struct FetchTurnsResult {} /// Stop receiving updates for a channel. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 61fd6dc8..c41db60f 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -979,6 +979,14 @@ pub struct ChatState { pub working_directory: Option, /// Completed turns pub turns: Vec, + /// Cursor for loading older completed turns into this chat state. + /// + /// Presence means `turns` is a tail window and more historical turns are + /// available. Pass this opaque cursor to `fetchTurns`; the host MUST insert + /// the loaded turns into state and update or clear this cursor before + /// responding. Absence means the state contains all retained turns. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turns_next_cursor: Option, /// Currently in-progress turn #[serde(default, skip_serializing_if = "Option::is_none")] pub active_turn: Option, diff --git a/clients/rust/crates/ahp-types/src/version.rs b/clients/rust/crates/ahp-types/src/version.rs index 5b04e5bc..63db421e 100644 --- a/clients/rust/crates/ahp-types/src/version.rs +++ b/clients/rust/crates/ahp-types/src/version.rs @@ -13,4 +13,4 @@ pub const PROTOCOL_VERSION: &str = "0.5.1"; /// Consumers building `InitializeParams` should pass this slice (or a /// derived `Vec`) so the same client binary can fall back to /// older protocol versions if the host doesn't accept the newest one. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["0.5.1", "0.5.0"]; +pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["0.5.1"]; diff --git a/clients/rust/crates/ahp/src/client.rs b/clients/rust/crates/ahp/src/client.rs index 50ca34cd..5381eea8 100644 --- a/clients/rust/crates/ahp/src/client.rs +++ b/clients/rust/crates/ahp/src/client.rs @@ -31,7 +31,8 @@ use std::time::Duration; use ahp_types::actions::{ActionEnvelope, StateAction}; use ahp_types::commands::{ DispatchActionParams, InitializeParams, InitializeResult, ReconnectParams, ReconnectResult, - SubscribeParams, SubscribeResult, SubscriptionDeliveryOptions, UnsubscribeParams, + SubscribeParams, SubscribeResult, SubscribeView, SubscriptionDeliveryOptions, + UnsubscribeParams, }; use ahp_types::common::{Uri, ROOT_RESOURCE_URI}; use ahp_types::messages::{ @@ -400,6 +401,17 @@ impl Client { &self, uri: String, delivery: Option, + ) -> Result<(SubscribeResult, SessionSubscription), ClientError> { + self.subscribe_with_options(uri, delivery, None).await + } + + /// Subscribe to a URI with advisory delivery preferences and snapshot view + /// preferences. + pub async fn subscribe_with_options( + &self, + uri: String, + delivery: Option, + view: Option, ) -> Result<(SubscribeResult, SessionSubscription), ClientError> { let sub = self.attach_subscription(&uri).await; let result: SubscribeResult = self @@ -408,6 +420,7 @@ impl Client { SubscribeParams { channel: uri, delivery, + view, }, ) .await?; diff --git a/clients/rust/crates/ahp/src/hosts/runtime.rs b/clients/rust/crates/ahp/src/hosts/runtime.rs index 51a5c1d1..ecd06647 100644 --- a/clients/rust/crates/ahp/src/hosts/runtime.rs +++ b/clients/rust/crates/ahp/src/hosts/runtime.rs @@ -624,6 +624,7 @@ impl HostRuntime { SubscribeParams { channel: uri.clone(), delivery: None, + view: None, }, ) .await diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index afacf342..3f628556 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -48,7 +48,7 @@ //! ); //! ``` -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::{SystemTime, UNIX_EPOCH}; use ahp_types::actions::{ @@ -923,6 +923,20 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu } }), StateAction::ChatTruncated(a) => apply_truncated(state, a.turn_id.as_deref()), + StateAction::ChatTurnsLoaded(a) => { + let existing_ids: HashSet<&str> = + state.turns.iter().map(|turn| turn.id.as_str()).collect(); + let mut older_turns: Vec = a + .turns + .iter() + .filter(|turn| !existing_ids.contains(turn.id.as_str())) + .cloned() + .collect(); + older_turns.append(&mut state.turns); + state.turns = older_turns; + state.turns_next_cursor = a.turns_next_cursor.clone(); + ReduceOutcome::Applied + } StateAction::ChatInputRequested(a) => { upsert_input_request(state, a.request.clone()); ReduceOutcome::Applied @@ -1288,6 +1302,7 @@ fn apply_truncated(state: &mut ChatState, turn_id: Option<&str>) -> ReduceOutcom match turn_id { None => { state.turns.clear(); + state.turns_next_cursor = None; } Some(id) => { let Some(idx) = state.turns.iter().position(|t| t.id == id) else { @@ -1658,6 +1673,7 @@ mod tests { interactivity: None, working_directory: None, turns: Vec::new(), + turns_next_cursor: None, active_turn: None, steering_message: None, queued_messages: None, diff --git a/clients/rust/release-metadata.json b/clients/rust/release-metadata.json index 1cdb02a6..8745116f 100644 --- a/clients/rust/release-metadata.json +++ b/clients/rust/release-metadata.json @@ -2,7 +2,6 @@ "client": "rust", "packageVersion": "0.5.0", "supportedProtocolVersions": [ - "0.5.1", - "0.5.0" + "0.5.1" ] } diff --git a/clients/swift/AHPApp/AHPApp/Store/AHPConnection.swift b/clients/swift/AHPApp/AHPApp/Store/AHPConnection.swift index 11c95ede..c983eba9 100644 --- a/clients/swift/AHPApp/AHPApp/Store/AHPConnection.swift +++ b/clients/swift/AHPApp/AHPApp/Store/AHPConnection.swift @@ -299,10 +299,10 @@ actor AHPConnection { return result.items } - func fetchTurns(session: String, before: String? = nil, limit: Int? = nil) async throws -> FetchTurnsResult { + func fetchTurns(session: String, cursor: String? = nil) async throws -> FetchTurnsResult { try await sendRequest( method: "fetchTurns", - params: FetchTurnsParams(channel: session, before: before, limit: limit) + params: FetchTurnsParams(channel: session, cursor: cursor) ) } @@ -1084,10 +1084,10 @@ private actor LegacyAHPConnection { } /// Fetch turns for a session. - func fetchTurns(session: String, before: String? = nil, limit: Int? = nil) async throws -> FetchTurnsResult { + func fetchTurns(session: String, cursor: String? = nil) async throws -> FetchTurnsResult { try await sendRequest( method: "fetchTurns", - params: FetchTurnsParams(channel: session, before: before, limit: limit) + params: FetchTurnsParams(channel: session, cursor: cursor) ) } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 95630c99..551ddd40 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -49,6 +49,7 @@ public enum ActionType: String, Codable, Sendable { case sessionCustomizationRemoved = "session/customizationRemoved" case sessionMcpServerStateChanged = "session/mcpServerStateChanged" case chatTruncated = "chat/truncated" + case chatTurnsLoaded = "chat/turnsLoaded" case sessionIsReadChanged = "session/isReadChanged" case sessionIsArchivedChanged = "session/isArchivedChanged" case sessionActivityChanged = "session/activityChanged" @@ -1251,6 +1252,24 @@ public struct ChatTruncatedAction: Codable, Sendable { } } +public struct ChatTurnsLoadedAction: Codable, Sendable { + public var type: ActionType + /// Older completed turns loaded into the state, ordered oldest-first. + public var turns: [Turn] + /// Opaque cursor for loading the next older page, if one remains. + public var turnsNextCursor: String? + + public init( + type: ActionType, + turns: [Turn], + turnsNextCursor: String? = nil + ) { + self.type = type + self.turns = turns + self.turnsNextCursor = turnsNextCursor + } +} + public struct SessionConfigChangedAction: Codable, Sendable { public var type: ActionType /// Updated config values @@ -1818,6 +1837,7 @@ public enum StateAction: Codable, Sendable { case sessionCustomizationRemoved(SessionCustomizationRemovedAction) case sessionMcpServerStateChanged(SessionMcpServerStateChangedAction) case chatTruncated(ChatTruncatedAction) + case chatTurnsLoaded(ChatTurnsLoadedAction) case sessionConfigChanged(SessionConfigChangedAction) case sessionMetaChanged(SessionMetaChangedAction) case changesetStatusChanged(ChangesetStatusChangedAction) @@ -1952,6 +1972,8 @@ public enum StateAction: Codable, Sendable { self = .sessionMcpServerStateChanged(try SessionMcpServerStateChangedAction(from: decoder)) case "chat/truncated": self = .chatTruncated(try ChatTruncatedAction(from: decoder)) + case "chat/turnsLoaded": + self = .chatTurnsLoaded(try ChatTurnsLoadedAction(from: decoder)) case "session/configChanged": self = .sessionConfigChanged(try SessionConfigChangedAction(from: decoder)) case "session/metaChanged": @@ -2062,6 +2084,7 @@ public enum StateAction: Codable, Sendable { case .sessionCustomizationRemoved(let v): try v.encode(to: encoder) case .sessionMcpServerStateChanged(let v): try v.encode(to: encoder) case .chatTruncated(let v): try v.encode(to: encoder) + case .chatTurnsLoaded(let v): try v.encode(to: encoder) case .sessionConfigChanged(let v): try v.encode(to: encoder) case .sessionMetaChanged(let v): try v.encode(to: encoder) case .changesetStatusChanged(let v): try v.encode(to: encoder) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index 6113d421..6cb15a84 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -231,13 +231,38 @@ public struct SubscribeParams: Codable, Sendable { /// updates while preserving the same reduced state. Omit this field for the /// server's default delivery behavior. public var delivery: SubscriptionDeliveryOptions? + /// Optional client-requested shape for the returned snapshot. + /// + /// Servers that do not understand a requested view ignore it and return their + /// default snapshot. Clients MUST tolerate receiving more state than requested. + public var view: SubscribeView? public init( channel: String, - delivery: SubscriptionDeliveryOptions? = nil + delivery: SubscriptionDeliveryOptions? = nil, + view: SubscribeView? = nil ) { self.channel = channel self.delivery = delivery + self.view = view + } +} + +public struct SubscribeView: Codable, Sendable { + /// Advisory number of most-recent completed turns to expose in a chat + /// snapshot. + /// + /// Servers MAY return more or fewer turns than requested. When omitted, the + /// host MUST return all retained turns. When older turns remain available, the + /// returned {@link ChatState} carries `turnsNextCursor`; clients pass that + /// cursor to `fetchTurns` to ask the host to page more turns into the chat + /// state. + public var turns: Int? + + public init( + turns: Int? = nil + ) { + self.turns = turns } } @@ -832,34 +857,27 @@ public struct CreateResourceWatchResult: Codable, Sendable { public struct FetchTurnsParams: Codable, Sendable { /// Channel URI this command targets. public var channel: String - /// Turn ID to fetch before (exclusive). Omit to fetch from the most recent turn. - public var before: String? - /// Maximum number of turns to return. Server MAY impose its own upper bound. - public var limit: Int? + /// Opaque cursor from `ChatState.turnsNextCursor`. + /// + /// The host MUST reject unrecognised cursors with `InvalidParams`. Omit only + /// when asking the host to opportunistically load its next older page for the + /// chat, if any. + public var cursor: String? public init( channel: String, - before: String? = nil, - limit: Int? = nil + cursor: String? = nil ) { self.channel = channel - self.before = before - self.limit = limit + self.cursor = cursor } } public struct FetchTurnsResult: Codable, Sendable { - /// The requested turns, ordered oldest-first - public var turns: [Turn] - /// Whether more turns exist before the returned range - public var hasMore: Bool public init( - turns: [Turn], - hasMore: Bool + ) { - self.turns = turns - self.hasMore = hasMore } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index f575fdb6..af555275 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -860,6 +860,13 @@ public struct ChatState: Codable, Sendable { public var workingDirectory: String? /// Completed turns public var turns: [Turn] + /// Cursor for loading older completed turns into this chat state. + /// + /// Presence means `turns` is a tail window and more historical turns are + /// available. Pass this opaque cursor to `fetchTurns`; the host MUST insert + /// the loaded turns into state and update or clear this cursor before + /// responding. Absence means the state contains all retained turns. + public var turnsNextCursor: String? /// Currently in-progress turn public var activeTurn: ActiveTurn? /// Message to inject into the current turn at a convenient point @@ -893,6 +900,7 @@ public struct ChatState: Codable, Sendable { case interactivity case workingDirectory case turns + case turnsNextCursor case activeTurn case steeringMessage case queuedMessages @@ -911,6 +919,7 @@ public struct ChatState: Codable, Sendable { interactivity: ChatInteractivity? = nil, workingDirectory: String? = nil, turns: [Turn], + turnsNextCursor: String? = nil, activeTurn: ActiveTurn? = nil, steeringMessage: PendingMessage? = nil, queuedMessages: [PendingMessage]? = nil, @@ -927,6 +936,7 @@ public struct ChatState: Codable, Sendable { self.interactivity = interactivity self.workingDirectory = workingDirectory self.turns = turns + self.turnsNextCursor = turnsNextCursor self.activeTurn = activeTurn self.steeringMessage = steeringMessage self.queuedMessages = queuedMessages diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift index d4690313..c018beb7 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift @@ -14,5 +14,4 @@ public let PROTOCOL_VERSION: String = "0.5.1" /// protocol versions if the host doesn't accept the newest one. public let SUPPORTED_PROTOCOL_VERSIONS: [String] = [ "0.5.1", - "0.5.0", ] diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 0ed159e7..bf550bcf 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -403,12 +403,23 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { } var next = state next.turns = turns + if a.turnId == nil { + next.turnsNextCursor = nil + } next.activeTurn = nil next.inputRequests = nil next.status = chatSummaryStatus(next) next.modifiedAt = currentTimestamp() return next + case .chatTurnsLoaded(let a): + let existingIds = Set(state.turns.map(\.id)) + let olderTurns = a.turns.filter { !existingIds.contains($0.id) } + var next = state + next.turns = olderTurns + state.turns + next.turnsNextCursor = a.turnsNextCursor + return next + // ── Session Input Requests ───────────────────────────────────────────── case .chatInputRequested(let a): diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClient.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClient.swift index e7f59570..76f70781 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClient.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClient.swift @@ -310,13 +310,14 @@ public actor AHPClient { /// clean up the partially-attached subscription. public func subscribe( _ uri: String, - delivery: SubscriptionDeliveryOptions? = nil + delivery: SubscriptionDeliveryOptions? = nil, + view: SubscribeView? = nil ) async throws -> (SubscribeResult, AsyncStream) { let (stream, listenerId) = attachSubscriptionInternal(uri) do { let result: SubscribeResult = try await request( method: "subscribe", - params: SubscribeParams(channel: uri, delivery: delivery) + params: SubscribeParams(channel: uri, delivery: delivery, view: view) ) return (result, stream) } catch { diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md index 3bfc8257..e4bc82a9 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -31,6 +31,9 @@ the tag matches the version pinned in [`VERSION`](VERSION). `ListSessionsParams` and `ListSessionsResult` now carry these fields, letting clients page through a large session catalogue. Fully additive — omitting the fields preserves prior behaviour. +- `SubscribeParams.view.turns`, `ChatState.turnsNextCursor`, and the + `chat/turnsLoaded` action so clients can subscribe to a bounded tail of chat + history and page older turns into the reduced chat state on demand. - `SessionState.inputNeeded` — a session-level aggregate of outstanding input requests across all chats (`SessionInputRequest` enum with `SessionChatInputRequest`, `SessionToolConfirmationRequest`, and @@ -44,6 +47,14 @@ the tag matches the version pinned in [`VERSION`](VERSION). - Optional `model` and `tools` fields on `AgentCustomization` for a custom agent's pinned model and tool allowlist. +### Changed + +- `fetchTurns` now accepts `cursor` from `ChatState.turnsNextCursor` and returns + an empty result after the host has loaded older turns into chat state, instead + of returning a detached `{ turns, hasMore }` page. +- Generated clients now advertise only protocol `0.5.1`, since the `fetchTurns` + contract is not wire-compatible with `0.5.0`. + ### Removed - `filter` field from `ListSessionsParams`. It was an untyped placeholder with diff --git a/clients/swift/release-metadata.json b/clients/swift/release-metadata.json index 3765f19c..f05f7f5a 100644 --- a/clients/swift/release-metadata.json +++ b/clients/swift/release-metadata.json @@ -2,7 +2,6 @@ "client": "swift", "packageVersion": "0.5.0", "supportedProtocolVersions": [ - "0.5.1", - "0.5.0" + "0.5.1" ] } diff --git a/clients/typescript/CHANGELOG.md b/clients/typescript/CHANGELOG.md index 9623a328..e74b63d5 100644 --- a/clients/typescript/CHANGELOG.md +++ b/clients/typescript/CHANGELOG.md @@ -34,6 +34,17 @@ hotfix escape hatch. `ListSessionsParams` and `ListSessionsResult` now carry these fields, letting clients page through a large session catalogue. Fully additive — omitting the fields preserves prior behaviour. +- `SubscribeParams.view.turns`, `ChatState.turnsNextCursor`, and the + `chat/turnsLoaded` action so clients can subscribe to a bounded tail of chat + history and page older turns into the reduced chat state on demand. + +### Changed + +- `fetchTurns` now accepts `cursor` from `ChatState.turnsNextCursor` and returns + an empty result after the host has loaded older turns into chat state, instead + of returning a detached `{ turns, hasMore }` page. +- Generated clients now advertise only protocol `0.5.1`, since the `fetchTurns` + contract is not wire-compatible with `0.5.0`. - `SessionState.inputNeeded` — a session-level aggregate of outstanding input requests across all chats (`SessionInputRequest` union with `SessionChatInputRequest`, `SessionToolConfirmationRequest`, and diff --git a/clients/typescript/release-metadata.json b/clients/typescript/release-metadata.json index fd3c324d..a57ed3c5 100644 --- a/clients/typescript/release-metadata.json +++ b/clients/typescript/release-metadata.json @@ -2,7 +2,6 @@ "client": "typescript", "packageVersion": "0.5.0", "supportedProtocolVersions": [ - "0.5.1", - "0.5.0" + "0.5.1" ] } diff --git a/clients/typescript/src/client/client.ts b/clients/typescript/src/client/client.ts index 20bcec22..11cdfc98 100644 --- a/clients/typescript/src/client/client.ts +++ b/clients/typescript/src/client/client.ts @@ -20,6 +20,7 @@ import type { ReconnectParams, ReconnectResult, SubscribeParams, + SubscribeView, SubscriptionDeliveryOptions, SubscribeResult, UnsubscribeParams, @@ -80,6 +81,8 @@ export interface AhpClientConfig { export interface SubscribeOptions { /** Advisory delivery preferences for this subscription. */ delivery?: SubscriptionDeliveryOptions; + /** Optional client-requested shape for the returned snapshot. */ + view?: SubscribeView; } const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; @@ -307,6 +310,7 @@ export class AhpClient { const params: SubscribeParams = { channel: uri, ...(options.delivery ? { delivery: options.delivery } : {}), + ...(options.view ? { view: options.view } : {}), }; const result = await this.request('subscribe', params); return { result, subscription }; diff --git a/clients/typescript/test/client.test.ts b/clients/typescript/test/client.test.ts index 3a0219af..65b35c87 100644 --- a/clients/typescript/test/client.test.ts +++ b/clients/typescript/test/client.test.ts @@ -107,11 +107,15 @@ test('subscribe attaches before sending the request and fans out an action', asy const client = new AhpClient(c); client.connect(); - const subPromise = client.subscribe('ahp-session:/s1', { delivery: { maxLatencyMs: 100 } }); + const subPromise = client.subscribe('ahp-session:/s1', { + delivery: { maxLatencyMs: 100 }, + view: { turns: 30 }, + }); const req = await readRequest(s); assert.equal(req.method, 'subscribe'); assert.equal((req.params as SubscribeParams).channel, 'ahp-session:/s1'); assert.deepEqual((req.params as SubscribeParams).delivery, { maxLatencyMs: 100 }); + assert.deepEqual((req.params as SubscribeParams).view, { turns: 30 }); const result: SubscribeResult = { channel: 'ahp-session:/s1', diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index a39ecd92..c326c768 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -16,6 +16,20 @@ 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`, `workingDirectory`) 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)). 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. +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 +`view.turns` is omitted, the server MUST return all retained turns. If older +retained turns remain available, `ChatState.turnsNextCursor` is present. The +client passes this opaque cursor to +[`fetchTurns`](#commands-paramschannel--ahp-chatuuid) to ask the host to +dispatch `chat/turnsLoaded`, which prepends older turns into the same reduced +chat state and updates or clears `turnsNextCursor`. + +Hosts MUST also eagerly load older turns into state before applying any +operation that references a turn outside the currently loaded window (for +example, a fork or truncation targeting an older turn). + ### Drafts [`ChatState.draft`](/reference/chat#chatstate) is the user's in-progress input for a chat — the [`Message`](/reference/chat#message) they are composing but have not sent yet, including its model/agent selection and attachments. Unlike the fields above, `draft` is state-only and is **not** mirrored onto [`ChatSummary`](/reference/chat#chatsummary). @@ -96,7 +110,7 @@ This section lists wire methods that are interpreted in the context of a chat UR | Method | Kind | Purpose | |---|---|---| -| `fetchTurns` | request | Page historical turns for this chat. | +| `fetchTurns` | request | Ask the host to load older historical turns into this chat state. | | `completions` | request | Chat-scoped inline completions (e.g. user-message mentions). | `createChat` is dispatched against the owning session URI (`params.channel = "ahp-session:/"`). diff --git a/docs/specification/root-channel.md b/docs/specification/root-channel.md index 312f3423..0695b884 100644 --- a/docs/specification/root-channel.md +++ b/docs/specification/root-channel.md @@ -38,7 +38,7 @@ A large catalogue can be fetched incrementally. [`listSessions`](/reference/root - If the result includes a `nextCursor`, more entries exist — pass it back as `cursor` on the next call to fetch the following page. - A missing `nextCursor` signals the end of the catalogue. -The cursor is **opaque and server-defined**: the server picks the ordering and keyset, exactly as `fetchTurns` exposes `before`/`hasMore` without dictating storage. Clients MUST NOT parse, modify, or persist a cursor across connections. An unrecognised cursor SHOULD be rejected with an `InvalidParams` error. The server SHOULD return most-recently-modified entries first, so the first page is the immediately useful one. +The cursor is **opaque and server-defined**: the server picks the ordering and keyset. Clients MUST NOT parse, modify, or persist a cursor across connections. An unrecognised cursor SHOULD be rejected with an `InvalidParams` error. The server SHOULD return most-recently-modified entries first, so the first page is the immediately useful one. Pagination is fully additive. A client that omits `limit`/`cursor` and ignores `nextCursor` sees the pre-pagination behaviour (subject to any server-imposed cap), and a server that does not paginate ignores the inputs and returns everything in one page. Pagination governs only the initial and backfill fetches — the `root/session*` notifications keep an already-loaded page live exactly as before. diff --git a/docs/specification/subscriptions.md b/docs/specification/subscriptions.md index 50d1fe70..82504665 100644 --- a/docs/specification/subscriptions.md +++ b/docs/specification/subscriptions.md @@ -44,7 +44,8 @@ Future channel types (LSP relay, MCP relay, …) introduce their own URI schemes "method": "subscribe", "params": { "channel": "ahp-session:/", - "delivery": { "maxLatencyMs": 100 } + "delivery": { "maxLatencyMs": 100 }, + "view": { "turns": 30 } } } @@ -85,6 +86,21 @@ while preserving the same reduced state a client would observe from immediate delivery. A value of `0` requests immediate delivery with no intentional coalescing. Omitting `delivery` uses the server's default delivery behavior. +### Snapshot views + +Clients MAY include `view` on `subscribe` to ask the server to shape the +returned snapshot. View preferences are advisory and additive: a server that +does not understand a requested view ignores it and returns its default +snapshot, and clients MUST tolerate receiving more state than requested. + +For chat channels, `view.turns` asks the server to expose approximately that +many most-recent completed turns in the snapshot. The value is advisory: the +server MAY return more or fewer turns than requested. If `view.turns` is +omitted, the server MUST return all retained turns. If older retained turns +remain available, the returned `ChatState` includes `turnsNextCursor`; the +client can pass that cursor to `fetchTurns` to ask the host to page older turns +into the same reduced state. + ## Unsubscribe (Notification) `unsubscribe` is a fire-and-forget client → server notification. Like every wire message, its params carry the channel URI being released. diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 9012e298..f44dcefb 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -1206,6 +1206,30 @@ "type" ] }, + "ChatTurnsLoadedAction": { + "type": "object", + "description": "Loads older completed turns into this chat's state.\n\nHosts dispatch this before responding to `fetchTurns`, and before applying\nany operation that references a turn older than the currently loaded window.\n`turns` is ordered oldest-first and is prepended to the current `turns`\nwindow. `turnsNextCursor` replaces the state's cursor; omit it when all\nretained turns are now loaded.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.ChatTurnsLoaded" + }, + "turns": { + "type": "array", + "items": { + "$ref": "#/$defs/Turn" + }, + "description": "Older completed turns loaded into the state, ordered oldest-first." + }, + "turnsNextCursor": { + "type": "string", + "description": "Opaque cursor for loading the next older page, if one remains." + } + }, + "required": [ + "type", + "turns" + ] + }, "ChatPendingMessageSetAction": { "type": "object", "description": "A pending message was set (upsert semantics: creates or replaces).\n\nFor steering messages, this always replaces the single steering message.\nFor queued messages, if a message with the given `id` already exists it is\nupdated in place; otherwise it is appended to the queue. If the chat is\nidle when a queued message is set, the server SHOULD immediately consume it\nand start a new turn.\n\nA client is only allowed to send {@link MessageKind.User} messages.", @@ -1908,6 +1932,9 @@ { "$ref": "#/$defs/ChatTruncatedAction" }, + { + "$ref": "#/$defs/ChatTurnsLoadedAction" + }, { "$ref": "#/$defs/ChatPendingMessageSetAction" }, @@ -4005,6 +4032,10 @@ }, "description": "Completed turns" }, + "turnsNextCursor": { + "type": "string", + "description": "Cursor for loading older completed turns into this chat state.\n\nPresence means `turns` is a tail window and more historical turns are\navailable. Pass this opaque cursor to `fetchTurns`; the host MUST insert\nthe loaded turns into state and update or clear this cursor before\nresponding. Absence means the state contains all retained turns." + }, "activeTurn": { "$ref": "#/$defs/ActiveTurn", "description": "Currently in-progress turn" @@ -6811,6 +6842,9 @@ { "$ref": "#/$defs/ChatTruncatedAction" }, + { + "$ref": "#/$defs/ChatTurnsLoadedAction" + }, { "$ref": "#/$defs/ChangesetStatusChangedAction" }, diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 0aa780c9..523db733 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -246,12 +246,26 @@ "delivery": { "$ref": "#/$defs/SubscriptionDeliveryOptions", "description": "Optional delivery preferences for this subscription.\n\nServers MAY use these preferences to buffer and coalesce high-frequency\nupdates while preserving the same reduced state. Omit this field for the\nserver's default delivery behavior." + }, + "view": { + "$ref": "#/$defs/SubscribeView", + "description": "Optional client-requested shape for the returned snapshot.\n\nServers that do not understand a requested view ignore it and return their\ndefault snapshot. Clients MUST tolerate receiving more state than requested." } }, "required": [ "channel" ] }, + "SubscribeView": { + "type": "object", + "description": "Optional client-requested shape for a subscription snapshot.", + "properties": { + "turns": { + "type": "number", + "description": "Advisory number of most-recent completed turns to expose in a chat\nsnapshot.\n\nServers MAY return more or fewer turns than requested. When omitted, the\nhost MUST return all retained turns. When older turns remain available, the\nreturned {@link ChatState} carries `turnsNextCursor`; clients pass that\ncursor to `fetchTurns` to ask the host to page more turns into the chat\nstate." + } + } + }, "SubscriptionDeliveryOptions": { "type": "object", "description": "Advisory delivery preferences for a single subscription.", @@ -949,19 +963,15 @@ }, "FetchTurnsParams": { "type": "object", - "description": "Fetches historical turns for a chat. Used for lazy loading of conversation\nhistory.", + "description": "Requests that the host load older historical turns into a chat state.\n\nThe command result does not carry turns. Instead, before responding, the host\nMUST dispatch `chat/turnsLoaded` to insert any loaded turns into the chat\nchannel's `turns` state, ahead of the already-loaded window, and update or\nclear `turnsNextCursor`.\n\nBefore applying any operation that references a turn outside the currently\nloaded window, the host MUST eagerly load enough older turns into state for\nthat operation to reduce against valid state.", "properties": { "channel": { "$ref": "#/$defs/URI", "description": "Chat URI" }, - "before": { + "cursor": { "type": "string", - "description": "Turn ID to fetch before (exclusive). Omit to fetch from the most recent turn." - }, - "limit": { - "type": "number", - "description": "Maximum number of turns to return. Server MAY impose its own upper bound." + "description": "Opaque cursor from `ChatState.turnsNextCursor`.\n\nThe host MUST reject unrecognised cursors with `InvalidParams`. Omit only\nwhen asking the host to opportunistically load its next older page for the\nchat, if any." } }, "required": [ @@ -971,23 +981,7 @@ "FetchTurnsResult": { "type": "object", "description": "Result of the `fetchTurns` command.", - "properties": { - "turns": { - "type": "array", - "items": { - "$ref": "#/$defs/Turn" - }, - "description": "The requested turns, ordered oldest-first" - }, - "hasMore": { - "type": "boolean", - "description": "Whether more turns exist before the returned range" - } - }, - "required": [ - "turns", - "hasMore" - ] + "properties": {} }, "CompletionsParams": { "type": "object", @@ -3350,6 +3344,10 @@ }, "description": "Completed turns" }, + "turnsNextCursor": { + "type": "string", + "description": "Cursor for loading older completed turns into this chat state.\n\nPresence means `turns` is a tail window and more historical turns are\navailable. Pass this opaque cursor to `fetchTurns`; the host MUST insert\nthe loaded turns into state and update or clear this cursor before\nresponding. Absence means the state contains all retained turns." + }, "activeTurn": { "$ref": "#/$defs/ActiveTurn", "description": "Currently in-progress turn" @@ -6811,6 +6809,30 @@ "type" ] }, + "ChatTurnsLoadedAction": { + "type": "object", + "description": "Loads older completed turns into this chat's state.\n\nHosts dispatch this before responding to `fetchTurns`, and before applying\nany operation that references a turn older than the currently loaded window.\n`turns` is ordered oldest-first and is prepended to the current `turns`\nwindow. `turnsNextCursor` replaces the state's cursor; omit it when all\nretained turns are now loaded.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.ChatTurnsLoaded" + }, + "turns": { + "type": "array", + "items": { + "$ref": "#/$defs/Turn" + }, + "description": "Older completed turns loaded into the state, ordered oldest-first." + }, + "turnsNextCursor": { + "type": "string", + "description": "Opaque cursor for loading the next older page, if one remains." + } + }, + "required": [ + "type", + "turns" + ] + }, "ChatPendingMessageSetAction": { "type": "object", "description": "A pending message was set (upsert semantics: creates or replaces).\n\nFor steering messages, this always replaces the single steering message.\nFor queued messages, if a message with the given `id` already exists it is\nupdated in place; otherwise it is appended to the queue. If the chat is\nidle when a queued message is set, the server SHOULD immediately consume it\nand start a new turn.\n\nA client is only allowed to send {@link MessageKind.User} messages.", diff --git a/schema/errors.schema.json b/schema/errors.schema.json index e6ed6f7e..372ea9af 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -2168,6 +2168,10 @@ }, "description": "Completed turns" }, + "turnsNextCursor": { + "type": "string", + "description": "Cursor for loading older completed turns into this chat state.\n\nPresence means `turns` is a tail window and more historical turns are\navailable. Pass this opaque cursor to `fetchTurns`; the host MUST insert\nthe loaded turns into state and update or clear this cursor before\nresponding. Absence means the state contains all retained turns." + }, "activeTurn": { "$ref": "#/$defs/ActiveTurn", "description": "Currently in-progress turn" @@ -4669,12 +4673,26 @@ "delivery": { "$ref": "#/$defs/SubscriptionDeliveryOptions", "description": "Optional delivery preferences for this subscription.\n\nServers MAY use these preferences to buffer and coalesce high-frequency\nupdates while preserving the same reduced state. Omit this field for the\nserver's default delivery behavior." + }, + "view": { + "$ref": "#/$defs/SubscribeView", + "description": "Optional client-requested shape for the returned snapshot.\n\nServers that do not understand a requested view ignore it and return their\ndefault snapshot. Clients MUST tolerate receiving more state than requested." } }, "required": [ "channel" ] }, + "SubscribeView": { + "type": "object", + "description": "Optional client-requested shape for a subscription snapshot.", + "properties": { + "turns": { + "type": "number", + "description": "Advisory number of most-recent completed turns to expose in a chat\nsnapshot.\n\nServers MAY return more or fewer turns than requested. When omitted, the\nhost MUST return all retained turns. When older turns remain available, the\nreturned {@link ChatState} carries `turnsNextCursor`; clients pass that\ncursor to `fetchTurns` to ask the host to page more turns into the chat\nstate." + } + } + }, "SubscriptionDeliveryOptions": { "type": "object", "description": "Advisory delivery preferences for a single subscription.", @@ -5372,19 +5390,15 @@ }, "FetchTurnsParams": { "type": "object", - "description": "Fetches historical turns for a chat. Used for lazy loading of conversation\nhistory.", + "description": "Requests that the host load older historical turns into a chat state.\n\nThe command result does not carry turns. Instead, before responding, the host\nMUST dispatch `chat/turnsLoaded` to insert any loaded turns into the chat\nchannel's `turns` state, ahead of the already-loaded window, and update or\nclear `turnsNextCursor`.\n\nBefore applying any operation that references a turn outside the currently\nloaded window, the host MUST eagerly load enough older turns into state for\nthat operation to reduce against valid state.", "properties": { "channel": { "$ref": "#/$defs/URI", "description": "Chat URI" }, - "before": { + "cursor": { "type": "string", - "description": "Turn ID to fetch before (exclusive). Omit to fetch from the most recent turn." - }, - "limit": { - "type": "number", - "description": "Maximum number of turns to return. Server MAY impose its own upper bound." + "description": "Opaque cursor from `ChatState.turnsNextCursor`.\n\nThe host MUST reject unrecognised cursors with `InvalidParams`. Omit only\nwhen asking the host to opportunistically load its next older page for the\nchat, if any." } }, "required": [ @@ -5394,23 +5408,7 @@ "FetchTurnsResult": { "type": "object", "description": "Result of the `fetchTurns` command.", - "properties": { - "turns": { - "type": "array", - "items": { - "$ref": "#/$defs/Turn" - }, - "description": "The requested turns, ordered oldest-first" - }, - "hasMore": { - "type": "boolean", - "description": "Whether more turns exist before the returned range" - } - }, - "required": [ - "turns", - "hasMore" - ] + "properties": {} }, "CompletionsParams": { "type": "object", diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index abe3e99a..36d68856 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -2328,6 +2328,10 @@ }, "description": "Completed turns" }, + "turnsNextCursor": { + "type": "string", + "description": "Cursor for loading older completed turns into this chat state.\n\nPresence means `turns` is a tail window and more historical turns are\navailable. Pass this opaque cursor to `fetchTurns`; the host MUST insert\nthe loaded turns into state and update or clear this cursor before\nresponding. Absence means the state contains all retained turns." + }, "activeTurn": { "$ref": "#/$defs/ActiveTurn", "description": "Currently in-progress turn" diff --git a/schema/state.schema.json b/schema/state.schema.json index 4df40258..d0c35738 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -2079,6 +2079,10 @@ }, "description": "Completed turns" }, + "turnsNextCursor": { + "type": "string", + "description": "Cursor for loading older completed turns into this chat state.\n\nPresence means `turns` is a tail window and more historical turns are\navailable. Pass this opaque cursor to `fetchTurns`; the host MUST insert\nthe loaded turns into state and update or clear this cursor before\nresponding. Absence means the state contains all retained turns." + }, "activeTurn": { "$ref": "#/$defs/ActiveTurn", "description": "Currently in-progress turn" diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index e3e9627e..90e0bc66 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -1273,6 +1273,7 @@ const ACTION_VARIANTS: { { type: 'chat/inputAnswerChanged', variantName: 'ChatInputAnswerChanged', tsInterface: 'ChatInputAnswerChangedAction' }, { type: 'chat/inputCompleted', variantName: 'ChatInputCompleted', tsInterface: 'ChatInputCompletedAction' }, { type: 'chat/truncated', variantName: 'ChatTruncated', tsInterface: 'ChatTruncatedAction' }, + { type: 'chat/turnsLoaded', variantName: 'ChatTurnsLoaded', tsInterface: 'ChatTurnsLoadedAction' }, { type: 'session/isReadChanged', variantName: 'SessionIsReadChanged', tsInterface: 'SessionIsReadChangedAction' }, { type: 'session/isArchivedChanged', variantName: 'SessionIsArchivedChanged', tsInterface: 'SessionIsArchivedChangedAction' }, { type: 'session/activityChanged', variantName: 'SessionActivityChanged', tsInterface: 'SessionActivityChangedAction' }, @@ -1423,7 +1424,7 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: str { name: 'ReconnectParams' }, { name: 'ReconnectReplayResult', omitDiscriminants: true }, { name: 'ReconnectSnapshotResult', omitDiscriminants: true }, - { name: 'SubscribeParams' }, { name: 'SubscriptionDeliveryOptions' }, { name: 'SubscribeResult' }, + { name: 'SubscribeParams' }, { name: 'SubscribeView' }, { name: 'SubscriptionDeliveryOptions' }, { name: 'SubscribeResult' }, { name: 'SessionForkSource' }, { name: 'CreateSessionParams' }, { name: 'DisposeSessionParams' }, { name: 'ChatForkSource' }, { name: 'CreateChatParams' }, { name: 'DisposeChatParams' }, diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 01a9fe46..71c482df 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -1208,6 +1208,7 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'session/customizationRemoved', caseName: 'SessionCustomizationRemoved', tsInterface: 'SessionCustomizationRemovedAction' }, { type: 'session/mcpServerStateChanged', caseName: 'SessionMcpServerStateChanged', tsInterface: 'SessionMcpServerStateChangedAction' }, { type: 'chat/truncated', caseName: 'ChatTruncated', tsInterface: 'ChatTruncatedAction' }, + { type: 'chat/turnsLoaded', caseName: 'ChatTurnsLoaded', tsInterface: 'ChatTurnsLoadedAction' }, { type: 'session/configChanged', caseName: 'SessionConfigChanged', tsInterface: 'SessionConfigChangedAction' }, { type: 'session/metaChanged', caseName: 'SessionMetaChanged', tsInterface: 'SessionMetaChangedAction' }, { type: 'changeset/statusChanged', caseName: 'ChangesetStatusChanged', tsInterface: 'ChangesetStatusChangedAction' }, @@ -1398,7 +1399,7 @@ const COMMAND_STRUCTS = [ 'InitializeParams', 'InitializeResult', 'ClientCapabilities', 'ReconnectParams', 'ReconnectReplayResult', 'ReconnectSnapshotResult', - 'SubscribeParams', 'SubscriptionDeliveryOptions', 'SubscribeResult', + 'SubscribeParams', 'SubscribeView', 'SubscriptionDeliveryOptions', 'SubscribeResult', 'SessionForkSource', 'CreateSessionParams', 'DisposeSessionParams', 'ChatForkSource', 'CreateChatParams', 'DisposeChatParams', 'ListSessionsParams', 'ListSessionsResult', diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 3d408b9a..3b436969 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -1200,6 +1200,7 @@ const ACTION_VARIANTS: { { type: 'session/customizationRemoved', variantName: 'SessionCustomizationRemoved', tsInterface: 'SessionCustomizationRemovedAction' }, { type: 'session/mcpServerStateChanged', variantName: 'SessionMcpServerStateChanged', tsInterface: 'SessionMcpServerStateChangedAction', boxed: true }, { type: 'chat/truncated', variantName: 'ChatTruncated', tsInterface: 'ChatTruncatedAction' }, + { type: 'chat/turnsLoaded', variantName: 'ChatTurnsLoaded', tsInterface: 'ChatTurnsLoadedAction' }, { type: 'session/configChanged', variantName: 'SessionConfigChanged', tsInterface: 'SessionConfigChangedAction' }, { type: 'session/metaChanged', variantName: 'SessionMetaChanged', tsInterface: 'SessionMetaChangedAction' }, { type: 'changeset/statusChanged', variantName: 'ChangesetStatusChanged', tsInterface: 'ChangesetStatusChangedAction' }, @@ -1265,7 +1266,7 @@ pub struct ${scope}ToolCallConfirmedAction { function generateActionsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, Customization, ErrorInfo, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolResultContent, UsageInfo, Message, PendingMessageKind, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); + lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, Customization, ErrorInfo, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); lines.push(''); // ActionType enum @@ -1372,7 +1373,7 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: s { name: 'ReconnectParams' }, { name: 'ReconnectReplayResult', omitDiscriminants: true }, { name: 'ReconnectSnapshotResult', omitDiscriminants: true }, - { name: 'SubscribeParams' }, { name: 'SubscriptionDeliveryOptions' }, { name: 'SubscribeResult' }, + { name: 'SubscribeParams' }, { name: 'SubscribeView' }, { name: 'SubscriptionDeliveryOptions' }, { name: 'SubscribeResult' }, { name: 'SessionForkSource' }, { name: 'CreateSessionParams' }, { name: 'DisposeSessionParams' }, { name: 'ChatForkSource' }, { name: 'CreateChatParams' }, @@ -1466,6 +1467,7 @@ function generateSubscribeParamsImplRust(): string { Self { channel: channel.into(), delivery: None, + view: None, } } @@ -1474,6 +1476,16 @@ function generateSubscribeParamsImplRust(): string { Self { channel: channel.into(), delivery: Some(delivery), + view: None, + } + } + + /// Create subscribe params with snapshot-shaping preferences. + pub fn with_view(channel: impl Into, view: SubscribeView) -> Self { + Self { + channel: channel.into(), + delivery: None, + view: Some(view), } } }`; diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index d32c7630..3d7792ee 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -1116,6 +1116,7 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'session/customizationRemoved', caseName: 'sessionCustomizationRemoved', tsInterface: 'SessionCustomizationRemovedAction' }, { type: 'session/mcpServerStateChanged', caseName: 'sessionMcpServerStateChanged', tsInterface: 'SessionMcpServerStateChangedAction' }, { type: 'chat/truncated', caseName: 'chatTruncated', tsInterface: 'ChatTruncatedAction' }, + { type: 'chat/turnsLoaded', caseName: 'chatTurnsLoaded', tsInterface: 'ChatTurnsLoadedAction' }, { type: 'session/configChanged', caseName: 'sessionConfigChanged', tsInterface: 'SessionConfigChangedAction' }, { type: 'session/metaChanged', caseName: 'sessionMetaChanged', tsInterface: 'SessionMetaChangedAction' }, { type: 'changeset/statusChanged', caseName: 'changesetStatusChanged', tsInterface: 'ChangesetStatusChangedAction' }, @@ -1315,7 +1316,7 @@ const COMMAND_ENUMS = ['ReconnectResultType', 'ContentEncoding', 'CompletionItem const COMMAND_STRUCTS = [ 'InitializeParams', 'InitializeResult', 'ClientCapabilities', 'ReconnectParams', 'ReconnectReplayResult', 'ReconnectSnapshotResult', - 'SubscribeParams', 'SubscriptionDeliveryOptions', 'SubscribeResult', + 'SubscribeParams', 'SubscribeView', 'SubscriptionDeliveryOptions', 'SubscribeResult', 'SessionForkSource', 'CreateSessionParams', 'DisposeSessionParams', 'ChatForkSource', 'CreateChatParams', 'DisposeChatParams', 'ListSessionsParams', 'ListSessionsResult', diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index 150dbc1a..6f0c78d3 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -54,6 +54,7 @@ import type { ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, + ChatTurnsLoadedAction, ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, @@ -188,6 +189,7 @@ export type ChatAction = | ChatInputAnswerChangedAction | ChatInputCompletedAction | ChatTruncatedAction + | ChatTurnsLoadedAction ; /** Union of chat actions that clients may dispatch. */ @@ -220,6 +222,7 @@ export type ServerChatAction = | ChatUsageAction | ChatReasoningAction | ChatInputRequestedAction + | ChatTurnsLoadedAction ; /** Union of all terminal-scoped actions. */ @@ -379,6 +382,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatInputAnswerChanged]: true, [ActionType.ChatInputCompleted]: true, [ActionType.ChatTruncated]: true, + [ActionType.ChatTurnsLoaded]: false, [ActionType.ChangesetStatusChanged]: false, [ActionType.ChangesetFileSet]: false, [ActionType.ChangesetFileRemoved]: false, diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index 43e867e3..0fd6b8d4 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -16,6 +16,7 @@ import type { ChatInputResponseKind, ConfirmationOption, ToolCallContributor, + Turn, } from './state.js'; import { ToolCallConfirmationReason, @@ -496,6 +497,26 @@ export interface ChatTruncatedAction { turnId?: string; } +/** + * Loads older completed turns into this chat's state. + * + * Hosts dispatch this before responding to `fetchTurns`, and before applying + * any operation that references a turn older than the currently loaded window. + * `turns` is ordered oldest-first and is prepended to the current `turns` + * window. `turnsNextCursor` replaces the state's cursor; omit it when all + * retained turns are now loaded. + * + * @category Chat Actions + * @version 1 + */ +export interface ChatTurnsLoadedAction { + type: ActionType.ChatTurnsLoaded; + /** Older completed turns loaded into the state, ordered oldest-first. */ + turns: Turn[]; + /** Opaque cursor for loading the next older page, if one remains. */ + turnsNextCursor?: string; +} + // ─── Pending Message Actions ───────────────────────────────────────────────── /** @@ -662,6 +683,7 @@ export type ChatAction = | ChatUsageAction | ChatReasoningAction | ChatTruncatedAction + | ChatTurnsLoadedAction | ChatPendingMessageSetAction | ChatPendingMessageRemovedAction | ChatQueuedMessagesReorderedAction diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index baf95fa7..4a15e339 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -545,12 +545,25 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st modifiedAt: new Date(Date.now()).toISOString(), }; delete next.inputRequests; + if (action.turnId === undefined) { + delete next.turnsNextCursor; + } return { ...next, status: summaryStatus(next), }; } + case ActionType.ChatTurnsLoaded: { + const existingIds = new Set(state.turns.map(turn => turn.id)); + const olderTurns = action.turns.filter(turn => !existingIds.has(turn.id)); + return { + ...state, + turns: [...olderTurns, ...state.turns], + turnsNextCursor: action.turnsNextCursor, + }; + } + // ── Session Input Requests ───────────────────────────────────────────── case ActionType.ChatInputRequested: diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index 2ab6d4ec..79d9ca7f 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -71,6 +71,15 @@ export interface ChatState { // ── Conversation contents ────────────────────────────────────────── /** Completed turns */ turns: Turn[]; + /** + * Cursor for loading older completed turns into this chat state. + * + * Presence means `turns` is a tail window and more historical turns are + * available. Pass this opaque cursor to `fetchTurns`; the host MUST insert + * the loaded turns into state and update or clear this cursor before + * responding. Absence means the state contains all retained turns. + */ + turnsNextCursor?: string; /** Currently in-progress turn */ activeTurn?: ActiveTurn; /** Message to inject into the current turn at a convenient point */ diff --git a/types/channels-session/commands.ts b/types/channels-session/commands.ts index 84102b52..d6b11eb1 100644 --- a/types/channels-session/commands.ts +++ b/types/channels-session/commands.ts @@ -11,7 +11,6 @@ import type { SessionActiveClient, } from './state.js'; import type { - Turn, MessageAttachment, } from '../channels-chat/state.js'; @@ -121,8 +120,16 @@ export interface DisposeSessionParams extends BaseParams {} // ─── fetchTurns ────────────────────────────────────────────────────────────── /** - * Fetches historical turns for a chat. Used for lazy loading of conversation - * history. + * Requests that the host load older historical turns into a chat state. + * + * The command result does not carry turns. Instead, before responding, the host + * MUST dispatch `chat/turnsLoaded` to insert any loaded turns into the chat + * channel's `turns` state, ahead of the already-loaded window, and update or + * clear `turnsNextCursor`. + * + * Before applying any operation that references a turn outside the currently + * loaded window, the host MUST eagerly load enough older turns into state for + * that operation to reduce against valid state. * * @category Commands * @method fetchTurns @@ -131,39 +138,31 @@ export interface DisposeSessionParams extends BaseParams {} * @version 1 * @example * ```jsonc - * // Client → Server (fetch the 20 most recent turns) + * // Client → Server (load the next page indicated by ChatState.turnsNextCursor) * { "jsonrpc": "2.0", "id": 8, "method": "fetchTurns", - * "params": { "channel": "ahp-chat:/", "limit": 20 } } - * - * // Server → Client - * { "jsonrpc": "2.0", "id": 8, "result": { - * "turns": [ { "id": "t1", ... }, { "id": "t2", ... } ], - * "hasMore": true - * }} + * "params": { "channel": "ahp-chat:/", "cursor": "opaque-cursor" } } * - * // Client → Server (fetch 20 turns before t1) - * { "jsonrpc": "2.0", "id": 9, "method": "fetchTurns", - * "params": { "channel": "ahp-chat:/", "before": "t1", "limit": 20 } } + * // Server updates chat state, then responds + * { "jsonrpc": "2.0", "id": 8, "result": {} } * ``` */ export interface FetchTurnsParams extends BaseParams { /** Chat URI */ channel: URI; - /** Turn ID to fetch before (exclusive). Omit to fetch from the most recent turn. */ - before?: string; - /** Maximum number of turns to return. Server MAY impose its own upper bound. */ - limit?: number; + /** + * Opaque cursor from `ChatState.turnsNextCursor`. + * + * The host MUST reject unrecognised cursors with `InvalidParams`. Omit only + * when asking the host to opportunistically load its next older page for the + * chat, if any. + */ + cursor?: string; } /** * Result of the `fetchTurns` command. */ -export interface FetchTurnsResult { - /** The requested turns, ordered oldest-first */ - turns: Turn[]; - /** Whether more turns exist before the returned range */ - hasMore: boolean; -} +export interface FetchTurnsResult {} // ─── completions ───────────────────────────────────────────────────────────── diff --git a/types/common/actions.ts b/types/common/actions.ts index 474dc9c3..01237f97 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -66,6 +66,7 @@ import type { ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, + ChatTurnsLoadedAction, } from '../channels-chat/actions.js'; import type { @@ -155,6 +156,7 @@ export const enum ActionType { SessionCustomizationRemoved = 'session/customizationRemoved', SessionMcpServerStateChanged = 'session/mcpServerStateChanged', ChatTruncated = 'chat/truncated', + ChatTurnsLoaded = 'chat/turnsLoaded', SessionIsReadChanged = 'session/isReadChanged', SessionIsArchivedChanged = 'session/isArchivedChanged', SessionActivityChanged = 'session/activityChanged', @@ -274,6 +276,7 @@ export type StateAction = | ChatInputAnswerChangedAction | ChatInputCompletedAction | ChatTruncatedAction + | ChatTurnsLoadedAction | ChangesetStatusChangedAction | ChangesetFileSetAction | ChangesetFileRemovedAction diff --git a/types/common/commands.ts b/types/common/commands.ts index c674d52e..7923c568 100644 --- a/types/common/commands.ts +++ b/types/common/commands.ts @@ -321,6 +321,32 @@ export interface SubscribeParams extends BaseParams { * server's default delivery behavior. */ delivery?: SubscriptionDeliveryOptions; + /** + * Optional client-requested shape for the returned snapshot. + * + * Servers that do not understand a requested view ignore it and return their + * default snapshot. Clients MUST tolerate receiving more state than requested. + */ + view?: SubscribeView; +} + +/** + * Optional client-requested shape for a subscription snapshot. + * + * @category Commands + */ +export interface SubscribeView { + /** + * Advisory number of most-recent completed turns to expose in a chat + * snapshot. + * + * Servers MAY return more or fewer turns than requested. When omitted, the + * host MUST return all retained turns. When older turns remain available, the + * returned {@link ChatState} carries `turnsNextCursor`; clients pass that + * cursor to `fetchTurns` to ask the host to page more turns into the chat + * state. + */ + turns?: number; } /** diff --git a/types/common/messages.ts b/types/common/messages.ts index 14918c98..e248b9e2 100644 --- a/types/common/messages.ts +++ b/types/common/messages.ts @@ -286,8 +286,8 @@ export type AhpServerRequest = ...; - * result.result.turns; // typed as Turn[] + * const result: AhpSuccessResponse<'listSessions'> = ...; + * result.result.items; // typed as SessionSummary[] * ``` */ export type AhpSuccessResponse = diff --git a/types/index.ts b/types/index.ts index ccec8598..f97048da 100644 --- a/types/index.ts +++ b/types/index.ts @@ -183,6 +183,7 @@ export type { ChatInputCompletedAction, ChatInputRequestedAction, ChatTruncatedAction, + ChatTurnsLoadedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, @@ -265,6 +266,7 @@ export type { ReconnectSnapshotResult, ReconnectResult, SubscribeParams, + SubscribeView, SubscriptionDeliveryOptions, SubscribeResult, CreateSessionParams, diff --git a/types/test-cases/reducers/232-chat-turnsloaded-prepends-older-turns.json b/types/test-cases/reducers/232-chat-turnsloaded-prepends-older-turns.json new file mode 100644 index 00000000..f5bd6e89 --- /dev/null +++ b/types/test-cases/reducers/232-chat-turnsloaded-prepends-older-turns.json @@ -0,0 +1,102 @@ +{ + "description": "chat/turnsLoaded prepends older turns and updates the next cursor", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "t3", + "message": { + "text": "Third", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ], + "turnsNextCursor": "cursor-1", + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + }, + "actions": [ + { + "type": "chat/turnsLoaded", + "turns": [ + { + "id": "t1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + }, + { + "id": "t2", + "message": { + "text": "Second", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ], + "turnsNextCursor": "cursor-2" + } + ], + "expected": { + "turns": [ + { + "id": "t1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + }, + { + "id": "t2", + "message": { + "text": "Second", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + }, + { + "id": "t3", + "message": { + "text": "Third", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ], + "turnsNextCursor": "cursor-2", + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + } +} diff --git a/types/test-cases/reducers/233-chat-turnsloaded-dedupes-overlap-and-clears-cursor.json b/types/test-cases/reducers/233-chat-turnsloaded-dedupes-overlap-and-clears-cursor.json new file mode 100644 index 00000000..16206a3f --- /dev/null +++ b/types/test-cases/reducers/233-chat-turnsloaded-dedupes-overlap-and-clears-cursor.json @@ -0,0 +1,113 @@ +{ + "description": "chat/turnsLoaded skips already-loaded turns and clears the cursor when omitted", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "t2", + "message": { + "text": "Second", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + }, + { + "id": "t3", + "message": { + "text": "Third", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ], + "turnsNextCursor": "cursor-1", + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + }, + "actions": [ + { + "type": "chat/turnsLoaded", + "turns": [ + { + "id": "t1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + }, + { + "id": "t2", + "message": { + "text": "Second from page", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ] + } + ], + "expected": { + "turns": [ + { + "id": "t1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + }, + { + "id": "t2", + "message": { + "text": "Second", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + }, + { + "id": "t3", + "message": { + "text": "Third", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ], + "turnsNextCursor": null, + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + } +} diff --git a/types/test-cases/reducers/234-chat-truncated-clears-turns-next-cursor-when-clearing-all.json b/types/test-cases/reducers/234-chat-truncated-clears-turns-next-cursor-when-clearing-all.json new file mode 100644 index 00000000..03329223 --- /dev/null +++ b/types/test-cases/reducers/234-chat-truncated-clears-turns-next-cursor-when-clearing-all.json @@ -0,0 +1,38 @@ +{ + "description": "chat/truncated clears turnsNextCursor when turnId is omitted", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "t1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ], + "turnsNextCursor": "cursor-1", + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + }, + "actions": [ + { + "type": "chat/truncated" + } + ], + "expected": { + "turns": [], + "activeTurn": null, + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:09.999Z" + } +} diff --git a/types/version/registry.ts b/types/version/registry.ts index 3969004c..04cc6658 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -35,7 +35,6 @@ export const PROTOCOL_VERSION = '0.5.1'; */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ '0.5.1', - '0.5.0', ]); // ─── SemVer Comparison ─────────────────────────────────────────────────────── @@ -125,6 +124,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatInputAnswerChanged]: '0.4.0', [ActionType.ChatInputCompleted]: '0.4.0', [ActionType.ChatTruncated]: '0.4.0', + [ActionType.ChatTurnsLoaded]: '0.5.1', [ActionType.ChangesetStatusChanged]: '0.2.0', [ActionType.ChangesetFileSet]: '0.2.0', [ActionType.ChangesetFileRemoved]: '0.2.0',