From 8fdb0ff1cc63ff56a701816d19c3b16d580f4aee Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 14 Jul 2026 09:45:33 -0700 Subject: [PATCH 1/9] chat: add side chats Add capability-gated side chats sourced from completed turns, preserve their origin, and allow bounded chat transcripts to be attached back into another chat. Regenerate schemas and clients and add cross-language round-trip coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahptypes/commands.generated.go | 40 +++++-- clients/go/ahptypes/roundtrip_fixture_test.go | 4 + clients/go/ahptypes/state.generated.go | 83 +++++++++++++- .../generated/Commands.generated.kt | 47 ++++++-- .../generated/State.generated.kt | 93 ++++++++++++++- .../agenthostprotocol/RoundTripCorpusTest.kt | 2 + clients/rust/crates/ahp-types/src/commands.rs | 41 +++++-- clients/rust/crates/ahp-types/src/state.rs | 79 ++++++++++++- .../ahp-types/tests/roundtrip_corpus.rs | 3 +- .../Generated/Commands.generated.swift | 40 +++++-- .../Generated/State.generated.swift | 102 ++++++++++++++++- .../TypesRoundTripFixtureTests.swift | 2 + .../typescript/test/types-round-trip.test.ts | 2 + docs/.changes/20260713-side-chats.json | 4 + docs/guide/state-model.md | 5 +- docs/proposals/multi-chat.md | 17 ++- docs/specification/chat-channel.md | 56 ++++++++- schema/actions.schema.json | 75 +++++++++++- schema/commands.schema.json | 108 +++++++++++++++--- schema/errors.schema.json | 108 +++++++++++++++--- schema/notifications.schema.json | 75 +++++++++++- schema/state.schema.json | 75 +++++++++++- scripts/generate-go.ts | 20 +++- scripts/generate-json-schema.test.ts | 12 ++ scripts/generate-json-schema.ts | 29 +++-- scripts/generate-kotlin.ts | 17 ++- scripts/generate-rust.ts | 15 ++- scripts/generate-swift.ts | 22 +++- types/channels-chat/commands.ts | 45 ++++++-- types/channels-chat/state.ts | 32 +++++- types/channels-root/state.ts | 16 ++- types/index.ts | 7 +- .../030-agent-side-chat-capability.json | 58 ++++++++++ .../round-trips/031-side-chat-origin.json | 36 ++++++ .../032-chat-transcript-attachment.json | 46 ++++++++ .../033-chat-source-side-chat.json | 18 +++ 36 files changed, 1299 insertions(+), 135 deletions(-) create mode 100644 docs/.changes/20260713-side-chats.json create mode 100644 types/test-cases/round-trips/030-agent-side-chat-capability.json create mode 100644 types/test-cases/round-trips/031-side-chat-origin.json create mode 100644 types/test-cases/round-trips/032-chat-transcript-attachment.json create mode 100644 types/test-cases/round-trips/033-chat-source-side-chat.json diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index 533f9d885..a090ccf5d 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -23,6 +23,16 @@ const ( ReconnectResultTypeSnapshot ReconnectResultType = "snapshot" ) +// How a new chat uses its source chat and turn. +type ChatSourceKind string + +const ( + // Copy source history through the referenced turn into the new chat. + ChatSourceKindFork ChatSourceKind = "fork" + // Supply source context without copying it into the new chat's visible history. + ChatSourceKindSideChat ChatSourceKind = "sideChat" +) + // Encoding of fetched content data. type ContentEncoding string @@ -374,11 +384,15 @@ type DisposeSessionParams struct { Channel URI `json:"channel"` } -// Identifies a source chat and turn to fork from. -type ChatForkSource struct { - // URI of the existing chat to fork from +// Identifies a source chat and completed turn for a new chat. +type ChatSource struct { + // How the source is used. + Kind ChatSourceKind `json:"kind"` + // URI of the existing source chat. Chat URI `json:"chat"` - // Turn ID in the source chat; content up to and including this turn's response is copied + // Completed turn in the source chat. For a fork, content through this turn is + // copied. For a side chat, that content is supplied as context but is not + // copied into the new chat's visible `turns`. TurnId string `json:"turnId"` } @@ -390,13 +404,19 @@ type CreateChatParams struct { Chat URI `json:"chat"` // Optional initial message for the new chat. InitialMessage *Message `json:"initialMessage,omitempty"` - // Optional source chat and turn to fork from. - Source *ChatForkSource `json:"source,omitempty"` + // Optional source chat and completed turn. + // + // The source chat MUST belong to this session. Clients MUST only request + // `kind: "fork"` when the selected agent advertises + // `capabilities.multipleChats.fork`, and + // `kind: "sideChat"` when the selected agent advertises + // `capabilities.multipleChats.sideChat`. + Source *ChatSource `json:"source,omitempty"` // Initial working-directory subset for this chat. Every entry MUST be // present in the owning session's `workingDirectories`; the server MUST // reject any entry that is not. When absent, the chat inherits the full - // session set. Forked chats (`source`) inherit the source chat's - // `workingDirectories`; this field is ignored for forked chats. + // session set. Forked chats (`source.kind === "fork"`) inherit the source + // chat's `workingDirectories`; this field is ignored for forks. // // A client MUST NOT supply this field unless the agent advertises // {@link AgentCapabilities.multipleWorkingDirectories}. @@ -408,8 +428,8 @@ type CreateChatParams struct { // {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY // reject creation that omits it, or fall back to the first of the chat's // directories. Fixed at creation and reported (read-only) on - // {@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork - // inherits the source chat's primary). + // {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a + // `source.kind === "fork"` chat inherits the source chat's primary). PrimaryWorkingDirectory *URI `json:"primaryWorkingDirectory,omitempty"` } diff --git a/clients/go/ahptypes/roundtrip_fixture_test.go b/clients/go/ahptypes/roundtrip_fixture_test.go index 4f1e363e2..834d30eef 100644 --- a/clients/go/ahptypes/roundtrip_fixture_test.go +++ b/clients/go/ahptypes/roundtrip_fixture_test.go @@ -242,6 +242,10 @@ func decodeAndReencode(t *testing.T, name, typ, inputJSON string) string { var v InitializeResult dec(&v) return enc(&v) + case "ChatSource": + var v ChatSource + dec(&v) + return enc(&v) default: t.Fatalf("%s: round-trip fixture: unknown wire type %q. Add a decode entry to decodeAndReencode.", name, typ) return "" diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 3f5ba0412..7c8c89a71 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -69,6 +69,8 @@ const ( ChatOriginKindUser ChatOriginKind = "user" // Forked from an existing chat at a specific turn. ChatOriginKindFork ChatOriginKind = "fork" + // Created as an independent side conversation from a specific turn. + ChatOriginKindSideChat ChatOriginKind = "sideChat" // Spawned by a tool call running in another chat (e.g. a sub-agent delegation). ChatOriginKindTool ChatOriginKind = "tool" ) @@ -200,6 +202,8 @@ const ( MessageAttachmentKindResource MessageAttachmentKind = "resource" // An attachment that references annotations on an annotations channel. MessageAttachmentKindAnnotations MessageAttachmentKind = "annotations" + // An attachment that references a bounded transcript from another chat. + MessageAttachmentKindChat MessageAttachmentKind = "chat" ) // Discriminant for response part types. @@ -582,7 +586,8 @@ type AgentCapabilities struct { // The agent can host more than one concurrent chat per session. When absent, // clients MUST NOT call `createChat` to open chats beyond the default one the // session starts with. An empty object `{}` advertises multi-chat without - // forking; set {@link MultipleChatsCapability.fork} to also allow forking. + // source-based creation; set {@link MultipleChatsCapability.fork} or + // {@link MultipleChatsCapability.sideChat} to allow the corresponding mode. MultipleChats *MultipleChatsCapability `json:"multipleChats,omitempty"` // The session's agent can be granted tool access to more than one working // directory. The directories are treated as equal peers except where the @@ -598,9 +603,18 @@ type AgentCapabilities struct { // Options for the {@link AgentCapabilities.multipleChats} capability. type MultipleChatsCapability struct { // The agent can fork a chat from a specific turn. When absent or `false`, - // clients MUST NOT pass a {@link ChatForkSource} (`source`) to `createChat`. + // clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to + // `createChat`. // Forking always implies multi-chat support. Fork *bool `json:"fork,omitempty"` + // The agent can create a side chat from a specific turn. When absent or + // `false`, clients MUST NOT pass a {@link ChatSource} with + // `kind: "sideChat"` to `createChat`. + // + // A side chat receives the source turn as context without copying the source + // transcript into its own visible history. Side-chat support always implies + // multi-chat support. + SideChat *bool `json:"sideChat,omitempty"` } // Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability. @@ -1684,6 +1698,50 @@ type MessageAnnotationsAttachment struct { AnnotationIds []string `json:"annotationIds,omitempty"` } +// An attachment that references a chat transcript through a fixed completed +// turn. +// +// The referenced chat MUST belong to the same session as the message's chat. +// The host resolves the transcript from its first retained turn through +// `endTurn`, inclusive, when accepting the message. Later turns do not +// change the context represented by an already-sent attachment. +// +// Hosts MUST NOT recursively expand chat attachments found inside the +// referenced transcript. Clients SHOULD keep rendering `label` if the +// referenced chat is later pruned, and treat opening `resource` as best-effort. +type MessageChatAttachment struct { + // A human-readable label for the attachment (e.g. the filename of a file + // attachment). Used for display in UI. + Label string `json:"label"` + // If defined, the range in {@link Message.text} that references this + // attachment. This is a text range, not a byte range. + Range *TextRange `json:"range,omitempty"` + // Advisory display hint for clients rendering this attachment. Recognized + // values include: + // + // - `'image'`: the attachment is an image + // - `'document'`: the attachment is a textual document + // - `'symbol'`: the attachment is a code symbol (e.g. a function or class) + // - `'directory'`: the attachment is a folder + // - `'selection'`: the attachment is a selection within a document + // + // Implementations MAY provide additional values; clients SHOULD fall back + // to a reasonable default when an unknown value is encountered. + DisplayKind *string `json:"displayKind,omitempty"` + // Additional implementation-defined metadata for the attachment. + // + // If the attachment was produced by the `completions` command, the client + // MUST preserve every property of `_meta` originally returned by the agent + // host when sending the user message containing the accepted completion. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` + // Discriminant + Type MessageAttachmentKind `json:"type"` + // URI of the referenced chat. + Resource URI `json:"resource"` + // Last completed turn included in the referenced transcript. + EndTurn string `json:"endTurn"` +} + type MarkdownResponsePart struct { // Discriminant Kind ResponsePartKind `json:"kind"` @@ -4155,6 +4213,7 @@ func (*SimpleMessageAttachment) isMessageAttachment() {} func (*MessageEmbeddedResourceAttachment) isMessageAttachment() {} func (*MessageResourceAttachment) isMessageAttachment() {} func (*MessageAnnotationsAttachment) isMessageAttachment() {} +func (*MessageChatAttachment) isMessageAttachment() {} // MessageAttachmentUnknown carries an unrecognized MessageAttachment variant — typically a discriminator value introduced by a newer protocol version. The original JSON object is preserved verbatim so that re-encoding round-trips faithfully. type MessageAttachmentUnknown struct { @@ -4194,6 +4253,12 @@ func (u *MessageAttachment) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "chat": + var value MessageChatAttachment + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value default: raw := make(json.RawMessage, len(data)) copy(raw, data) @@ -4742,6 +4807,14 @@ type ChatForkOrigin struct { func (*ChatForkOrigin) isChatOrigin() {} +type ChatSideChatOrigin struct { + Kind ChatOriginKind `json:"kind"` + Chat URI `json:"chat"` + TurnId string `json:"turnId"` +} + +func (*ChatSideChatOrigin) isChatOrigin() {} + type ChatToolOrigin struct { Kind ChatOriginKind `json:"kind"` Chat URI `json:"chat"` @@ -4774,6 +4847,12 @@ func (o *ChatOrigin) UnmarshalJSON(data []byte) error { return err } o.Value = &v + case "sideChat": + var v ChatSideChatOrigin + if err := json.Unmarshal(data, &v); err != nil { + return err + } + o.Value = &v case "tool": var v ChatToolOrigin if err := json.Unmarshal(data, &v); err != nil { 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 96ecb2c4a..6fa3e4268 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 @@ -32,6 +32,23 @@ enum class ReconnectResultType { SNAPSHOT } +/** + * How a new chat uses its source chat and turn. + */ +@Serializable +enum class ChatSourceKind { + /** + * Copy source history through the referenced turn into the new chat. + */ + @SerialName("fork") + FORK, + /** + * Supply source context without copying it into the new chat's visible history. + */ + @SerialName("sideChat") + SIDE_CHAT +} + /** * Encoding of fetched content data. */ @@ -450,13 +467,19 @@ data class DisposeSessionParams( ) @Serializable -data class ChatForkSource( +data class ChatSource( /** - * URI of the existing chat to fork from + * How the source is used. + */ + val kind: ChatSourceKind, + /** + * URI of the existing source chat. */ val chat: String, /** - * Turn ID in the source chat; content up to and including this turn's response is copied + * Completed turn in the source chat. For a fork, content through this turn is + * copied. For a side chat, that content is supplied as context but is not + * copied into the new chat's visible `turns`. */ val turnId: String ) @@ -476,15 +499,21 @@ data class CreateChatParams( */ val initialMessage: Message? = null, /** - * Optional source chat and turn to fork from. + * Optional source chat and completed turn. + * + * The source chat MUST belong to this session. Clients MUST only request + * `kind: "fork"` when the selected agent advertises + * `capabilities.multipleChats.fork`, and + * `kind: "sideChat"` when the selected agent advertises + * `capabilities.multipleChats.sideChat`. */ - val source: ChatForkSource? = null, + val source: ChatSource? = null, /** * Initial working-directory subset for this chat. Every entry MUST be * present in the owning session's `workingDirectories`; the server MUST * reject any entry that is not. When absent, the chat inherits the full - * session set. Forked chats (`source`) inherit the source chat's - * `workingDirectories`; this field is ignored for forked chats. + * session set. Forked chats (`source.kind === "fork"`) inherit the source + * chat's `workingDirectories`; this field is ignored for forks. * * A client MUST NOT supply this field unless the agent advertises * {@link AgentCapabilities.multipleWorkingDirectories}. @@ -498,8 +527,8 @@ data class CreateChatParams( * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY * reject creation that omits it, or fall back to the first of the chat's * directories. Fixed at creation and reported (read-only) on - * {@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork - * inherits the source chat's primary). + * {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a + * `source.kind === "fork"` chat inherits the source chat's primary). */ val primaryWorkingDirectory: String? = null ) 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 1607814e3..426a1633e 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 @@ -183,6 +183,11 @@ enum class ChatOriginKind { */ @SerialName("fork") FORK, + /** + * Created as an independent side conversation from a specific turn. + */ + @SerialName("sideChat") + SIDE_CHAT, /** * Spawned by a tool call running in another chat (e.g. a sub-agent delegation). */ @@ -380,7 +385,12 @@ enum class MessageAttachmentKind { * An attachment that references annotations on an annotations channel. */ @SerialName("annotations") - ANNOTATIONS + ANNOTATIONS, + /** + * An attachment that references a bounded transcript from another chat. + */ + @SerialName("chat") + CHAT } /** @@ -950,7 +960,8 @@ data class AgentCapabilities( * The agent can host more than one concurrent chat per session. When absent, * clients MUST NOT call `createChat` to open chats beyond the default one the * session starts with. An empty object `{}` advertises multi-chat without - * forking; set {@link MultipleChatsCapability.fork} to also allow forking. + * source-based creation; set {@link MultipleChatsCapability.fork} or + * {@link MultipleChatsCapability.sideChat} to allow the corresponding mode. */ val multipleChats: MultipleChatsCapability? = null, /** @@ -970,10 +981,21 @@ data class AgentCapabilities( data class MultipleChatsCapability( /** * The agent can fork a chat from a specific turn. When absent or `false`, - * clients MUST NOT pass a {@link ChatForkSource} (`source`) to `createChat`. + * clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to + * `createChat`. * Forking always implies multi-chat support. */ - val fork: Boolean? = null + val fork: Boolean? = null, + /** + * The agent can create a side chat from a specific turn. When absent or + * `false`, clients MUST NOT pass a {@link ChatSource} with + * `kind: "sideChat"` to `createChat`. + * + * A side chat receives the source turn as context without copying the source + * transcript into its own visible history. Side-chat support always implies + * multi-chat support. + */ + val sideChat: Boolean? = null ) @Serializable @@ -2318,6 +2340,55 @@ data class MessageAnnotationsAttachment( val annotationIds: List? = null ) +@Serializable +data class MessageChatAttachment( + /** + * A human-readable label for the attachment (e.g. the filename of a file + * attachment). Used for display in UI. + */ + val label: String, + /** + * If defined, the range in {@link Message.text} that references this + * attachment. This is a text range, not a byte range. + */ + val range: TextRange? = null, + /** + * Advisory display hint for clients rendering this attachment. Recognized + * values include: + * + * - `'image'`: the attachment is an image + * - `'document'`: the attachment is a textual document + * - `'symbol'`: the attachment is a code symbol (e.g. a function or class) + * - `'directory'`: the attachment is a folder + * - `'selection'`: the attachment is a selection within a document + * + * Implementations MAY provide additional values; clients SHOULD fall back + * to a reasonable default when an unknown value is encountered. + */ + val displayKind: String? = null, + /** + * Additional implementation-defined metadata for the attachment. + * + * If the attachment was produced by the `completions` command, the client + * MUST preserve every property of `_meta` originally returned by the agent + * host when sending the user message containing the accepted completion. + */ + @SerialName("_meta") + val meta: Map? = null, + /** + * Discriminant + */ + val type: MessageAttachmentKind, + /** + * URI of the referenced chat. + */ + val resource: String, + /** + * Last completed turn included in the referenced transcript. + */ + val endTurn: String +) + @Serializable data class MarkdownResponsePart( /** @@ -4565,6 +4636,7 @@ data class ResourceChange( sealed interface ChatOrigin { @JvmInline value class User(val value: ChatOriginUser) : ChatOrigin @JvmInline value class Fork(val value: ChatOriginFork) : ChatOrigin + @JvmInline value class SideChat(val value: ChatOriginSideChat) : ChatOrigin @JvmInline value class Tool(val value: ChatOriginTool) : ChatOrigin @JvmInline value class Unknown(val raw: JsonObject) : ChatOrigin } @@ -4588,6 +4660,13 @@ data class ChatOriginTool( val toolCallId: String, ) +@Serializable +data class ChatOriginSideChat( + val kind: ChatOriginKind = ChatOriginKind.SIDE_CHAT, + val chat: String, + val turnId: String, +) + internal object ChatOriginSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ChatOrigin") @@ -4598,6 +4677,7 @@ internal object ChatOriginSerializer : KSerializer { return when ((obj["kind"] as? JsonPrimitive)?.contentOrNull) { "user" -> ChatOrigin.User(input.json.decodeFromJsonElement(ChatOriginUser.serializer(), element)) "fork" -> ChatOrigin.Fork(input.json.decodeFromJsonElement(ChatOriginFork.serializer(), element)) + "sideChat" -> ChatOrigin.SideChat(input.json.decodeFromJsonElement(ChatOriginSideChat.serializer(), element)) "tool" -> ChatOrigin.Tool(input.json.decodeFromJsonElement(ChatOriginTool.serializer(), element)) else -> ChatOrigin.Unknown(obj) } @@ -4608,6 +4688,7 @@ internal object ChatOriginSerializer : KSerializer { val element: JsonElement = when (value) { is ChatOrigin.User -> output.json.encodeToJsonElement(ChatOriginUser.serializer(), value.value) is ChatOrigin.Fork -> output.json.encodeToJsonElement(ChatOriginFork.serializer(), value.value) + is ChatOrigin.SideChat -> output.json.encodeToJsonElement(ChatOriginSideChat.serializer(), value.value) is ChatOrigin.Tool -> output.json.encodeToJsonElement(ChatOriginTool.serializer(), value.value) is ChatOrigin.Unknown -> value.raw } @@ -5080,6 +5161,8 @@ value class MessageAttachmentEmbeddedResource(val value: MessageEmbeddedResource value class MessageAttachmentResource(val value: MessageResourceAttachment) : MessageAttachment @JvmInline value class MessageAttachmentAnnotations(val value: MessageAnnotationsAttachment) : MessageAttachment +@JvmInline +value class MessageAttachmentChat(val value: MessageChatAttachment) : MessageAttachment /** * Forward-compat catch-all for unknown MessageAttachment discriminators. * @@ -5108,6 +5191,7 @@ internal object MessageAttachmentSerializer : KSerializer { "embeddedResource" -> MessageAttachmentEmbeddedResource(input.json.decodeFromJsonElement(MessageEmbeddedResourceAttachment.serializer(), element)) "resource" -> MessageAttachmentResource(input.json.decodeFromJsonElement(MessageResourceAttachment.serializer(), element)) "annotations" -> MessageAttachmentAnnotations(input.json.decodeFromJsonElement(MessageAnnotationsAttachment.serializer(), element)) + "chat" -> MessageAttachmentChat(input.json.decodeFromJsonElement(MessageChatAttachment.serializer(), element)) else -> MessageAttachmentUnknown(obj) } } @@ -5120,6 +5204,7 @@ internal object MessageAttachmentSerializer : KSerializer { is MessageAttachmentEmbeddedResource -> output.json.encodeToJsonElement(MessageEmbeddedResourceAttachment.serializer(), value.value) is MessageAttachmentResource -> output.json.encodeToJsonElement(MessageResourceAttachment.serializer(), value.value) is MessageAttachmentAnnotations -> output.json.encodeToJsonElement(MessageAnnotationsAttachment.serializer(), value.value) + is MessageAttachmentChat -> output.json.encodeToJsonElement(MessageChatAttachment.serializer(), value.value) is MessageAttachmentUnknown -> value.raw } output.encodeJsonElement(element) diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt index cd2c23935..d11abc8a3 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt @@ -30,6 +30,7 @@ package com.microsoft.agenthostprotocol import com.microsoft.agenthostprotocol.generated.ActionEnvelope import com.microsoft.agenthostprotocol.generated.ChangesetOperationTarget +import com.microsoft.agenthostprotocol.generated.ChatSource import com.microsoft.agenthostprotocol.generated.Customization import com.microsoft.agenthostprotocol.generated.Implementation import com.microsoft.agenthostprotocol.generated.InitializeResult @@ -251,6 +252,7 @@ class RoundTripCorpusTest { "PartialSessionSummary" -> rt(PartialSessionSummary.serializer()) "Implementation" -> rt(Implementation.serializer()) "InitializeResult" -> rt(InitializeResult.serializer()) + "ChatSource" -> rt(ChatSource.serializer()) else -> fail( "$file: unknown wire type \"$typeName\". " + "Add a decode entry to decodeAndReencode.", diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index cc4e7a4c5..459c16f79 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -31,6 +31,17 @@ pub enum ReconnectResultType { Snapshot, } +/// How a new chat uses its source chat and turn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ChatSourceKind { + /// Copy source history through the referenced turn into the new chat. + #[serde(rename = "fork")] + Fork, + /// Supply source context without copying it into the new chat's visible history. + #[serde(rename = "sideChat")] + SideChat, +} + /// Encoding of fetched content data. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ContentEncoding { @@ -468,13 +479,17 @@ pub struct DisposeSessionParams { pub channel: Uri, } -/// Identifies a source chat and turn to fork from. +/// Identifies a source chat and completed turn for a new chat. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ChatForkSource { - /// URI of the existing chat to fork from +pub struct ChatSource { + /// How the source is used. + pub kind: ChatSourceKind, + /// URI of the existing source chat. pub chat: Uri, - /// Turn ID in the source chat; content up to and including this turn's response is copied + /// Completed turn in the source chat. For a fork, content through this turn is + /// copied. For a side chat, that content is supplied as context but is not + /// copied into the new chat's visible `turns`. pub turn_id: String, } @@ -489,14 +504,20 @@ pub struct CreateChatParams { /// Optional initial message for the new chat. #[serde(default, skip_serializing_if = "Option::is_none")] pub initial_message: Option, - /// Optional source chat and turn to fork from. + /// Optional source chat and completed turn. + /// + /// The source chat MUST belong to this session. Clients MUST only request + /// `kind: "fork"` when the selected agent advertises + /// `capabilities.multipleChats.fork`, and + /// `kind: "sideChat"` when the selected agent advertises + /// `capabilities.multipleChats.sideChat`. #[serde(default, skip_serializing_if = "Option::is_none")] - pub source: Option, + pub source: Option, /// Initial working-directory subset for this chat. Every entry MUST be /// present in the owning session's `workingDirectories`; the server MUST /// reject any entry that is not. When absent, the chat inherits the full - /// session set. Forked chats (`source`) inherit the source chat's - /// `workingDirectories`; this field is ignored for forked chats. + /// session set. Forked chats (`source.kind === "fork"`) inherit the source + /// chat's `workingDirectories`; this field is ignored for forks. /// /// A client MUST NOT supply this field unless the agent advertises /// {@link AgentCapabilities.multipleWorkingDirectories}. @@ -509,8 +530,8 @@ pub struct CreateChatParams { /// {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY /// reject creation that omits it, or fall back to the first of the chat's /// directories. Fixed at creation and reported (read-only) on - /// {@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork - /// inherits the source chat's primary). + /// {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a + /// `source.kind === "fork"` chat inherits the source chat's primary). #[serde(default, skip_serializing_if = "Option::is_none")] pub primary_working_directory: Option, } diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 18ca75394..d470c4ee0 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -146,6 +146,9 @@ pub enum ChatOriginKind { /// Forked from an existing chat at a specific turn. #[serde(rename = "fork")] Fork, + /// Created as an independent side conversation from a specific turn. + #[serde(rename = "sideChat")] + SideChat, /// Spawned by a tool call running in another chat (e.g. a sub-agent delegation). #[serde(rename = "tool")] Tool, @@ -294,6 +297,9 @@ pub enum MessageAttachmentKind { /// An attachment that references annotations on an annotations channel. #[serde(rename = "annotations")] Annotations, + /// An attachment that references a bounded transcript from another chat. + #[serde(rename = "chat")] + Chat, } /// Discriminant for response part types. @@ -800,7 +806,8 @@ pub struct AgentCapabilities { /// The agent can host more than one concurrent chat per session. When absent, /// clients MUST NOT call `createChat` to open chats beyond the default one the /// session starts with. An empty object `{}` advertises multi-chat without - /// forking; set {@link MultipleChatsCapability.fork} to also allow forking. + /// source-based creation; set {@link MultipleChatsCapability.fork} or + /// {@link MultipleChatsCapability.sideChat} to allow the corresponding mode. #[serde(default, skip_serializing_if = "Option::is_none")] pub multiple_chats: Option, /// The session's agent can be granted tool access to more than one working @@ -820,10 +827,20 @@ pub struct AgentCapabilities { #[serde(rename_all = "camelCase")] pub struct MultipleChatsCapability { /// The agent can fork a chat from a specific turn. When absent or `false`, - /// clients MUST NOT pass a {@link ChatForkSource} (`source`) to `createChat`. + /// clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to + /// `createChat`. /// Forking always implies multi-chat support. #[serde(default, skip_serializing_if = "Option::is_none")] pub fork: Option, + /// The agent can create a side chat from a specific turn. When absent or + /// `false`, clients MUST NOT pass a {@link ChatSource} with + /// `kind: "sideChat"` to `createChat`. + /// + /// A side chat receives the source turn as context without copying the source + /// transcript into its own visible history. Side-chat support always implies + /// multi-chat support. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side_chat: Option, } /// Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability. @@ -2104,6 +2121,53 @@ pub struct MessageAnnotationsAttachment { pub annotation_ids: Option>, } +/// An attachment that references a chat transcript through a fixed completed +/// turn. +/// +/// The referenced chat MUST belong to the same session as the message's chat. +/// The host resolves the transcript from its first retained turn through +/// `endTurn`, inclusive, when accepting the message. Later turns do not +/// change the context represented by an already-sent attachment. +/// +/// Hosts MUST NOT recursively expand chat attachments found inside the +/// referenced transcript. Clients SHOULD keep rendering `label` if the +/// referenced chat is later pruned, and treat opening `resource` as best-effort. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageChatAttachment { + /// A human-readable label for the attachment (e.g. the filename of a file + /// attachment). Used for display in UI. + pub label: String, + /// If defined, the range in {@link Message.text} that references this + /// attachment. This is a text range, not a byte range. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub range: Option, + /// Advisory display hint for clients rendering this attachment. Recognized + /// values include: + /// + /// - `'image'`: the attachment is an image + /// - `'document'`: the attachment is a textual document + /// - `'symbol'`: the attachment is a code symbol (e.g. a function or class) + /// - `'directory'`: the attachment is a folder + /// - `'selection'`: the attachment is a selection within a document + /// + /// Implementations MAY provide additional values; clients SHOULD fall back + /// to a reasonable default when an unknown value is encountered. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_kind: Option, + /// Additional implementation-defined metadata for the attachment. + /// + /// If the attachment was produced by the `completions` command, the client + /// MUST preserve every property of `_meta` originally returned by the agent + /// host when sending the user message containing the accepted completion. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// URI of the referenced chat. + pub resource: Uri, + /// Last completed turn included in the referenced transcript. + pub end_turn: String, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MarkdownResponsePart { @@ -4168,6 +4232,15 @@ pub enum ChatOrigin { #[serde(rename = "turnId")] turn_id: String, }, + /// Independent side conversation created from a specific turn. + #[serde(rename = "sideChat")] + SideChat { + /// URI of the chat that supplied the side-chat context. + chat: Uri, + /// Turn through which context was supplied. + #[serde(rename = "turnId")] + turn_id: String, + }, /// Spawned by a tool call in another chat. #[serde(rename = "tool")] Tool { @@ -4365,6 +4438,8 @@ pub enum MessageAttachment { Resource(MessageResourceAttachment), #[serde(rename = "annotations")] Annotations(MessageAnnotationsAttachment), + #[serde(rename = "chat")] + Chat(MessageChatAttachment), /// Unknown or future variant — preserved as raw JSON for round-trip fidelity. /// Reducers treat this as a no-op. #[serde(untagged)] diff --git a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs index b7549e36a..030a4720a 100644 --- a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs +++ b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs @@ -26,7 +26,7 @@ use ahp_types::{ actions::{ActionEnvelope, StateAction}, - commands::{ChangesetOperationTarget, Implementation, InitializeResult}, + commands::{ChangesetOperationTarget, ChatSource, Implementation, InitializeResult}, common::StringOrMarkdown, messages::JsonRpcMessage, notifications::{PartialSessionSummary, SessionAddedParams}, @@ -221,6 +221,7 @@ fn decode_and_reencode(file: &str, type_name: &str, input_json: &str) -> Result< "PartialSessionSummary" => round_trip!(PartialSessionSummary), "Implementation" => round_trip!(Implementation), "InitializeResult" => round_trip!(InitializeResult), + "ChatSource" => round_trip!(ChatSource), other => Err(format!( "{}: unknown wire type {:?}. Add a decode entry to decode_and_reencode.", file, other diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index 59f6eb603..e926ebfe6 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -10,6 +10,14 @@ public enum ReconnectResultType: String, Codable, Sendable { case snapshot = "snapshot" } +/// How a new chat uses its source chat and turn. +public enum ChatSourceKind: String, Codable, Sendable { + /// Copy source history through the referenced turn into the new chat. + case fork = "fork" + /// Supply source context without copying it into the new chat's visible history. + case sideChat = "sideChat" +} + /// Encoding of fetched content data. public enum ContentEncoding: String, Codable, Sendable { case base64 = "base64" @@ -446,16 +454,22 @@ public struct DisposeSessionParams: Codable, Sendable { } } -public struct ChatForkSource: Codable, Sendable { - /// URI of the existing chat to fork from +public struct ChatSource: Codable, Sendable { + /// How the source is used. + public var kind: ChatSourceKind + /// URI of the existing source chat. public var chat: String - /// Turn ID in the source chat; content up to and including this turn's response is copied + /// Completed turn in the source chat. For a fork, content through this turn is + /// copied. For a side chat, that content is supplied as context but is not + /// copied into the new chat's visible `turns`. public var turnId: String public init( + kind: ChatSourceKind, chat: String, turnId: String ) { + self.kind = kind self.chat = chat self.turnId = turnId } @@ -468,13 +482,19 @@ public struct CreateChatParams: Codable, Sendable { public var chat: String /// Optional initial message for the new chat. public var initialMessage: Message? - /// Optional source chat and turn to fork from. - public var source: ChatForkSource? + /// Optional source chat and completed turn. + /// + /// The source chat MUST belong to this session. Clients MUST only request + /// `kind: "fork"` when the selected agent advertises + /// `capabilities.multipleChats.fork`, and + /// `kind: "sideChat"` when the selected agent advertises + /// `capabilities.multipleChats.sideChat`. + public var source: ChatSource? /// Initial working-directory subset for this chat. Every entry MUST be /// present in the owning session's `workingDirectories`; the server MUST /// reject any entry that is not. When absent, the chat inherits the full - /// session set. Forked chats (`source`) inherit the source chat's - /// `workingDirectories`; this field is ignored for forked chats. + /// session set. Forked chats (`source.kind === "fork"`) inherit the source + /// chat's `workingDirectories`; this field is ignored for forks. /// /// A client MUST NOT supply this field unless the agent advertises /// {@link AgentCapabilities.multipleWorkingDirectories}. @@ -486,15 +506,15 @@ public struct CreateChatParams: Codable, Sendable { /// {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY /// reject creation that omits it, or fall back to the first of the chat's /// directories. Fixed at creation and reported (read-only) on - /// {@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork - /// inherits the source chat's primary). + /// {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a + /// `source.kind === "fork"` chat inherits the source chat's primary). public var primaryWorkingDirectory: String? public init( channel: String, chat: String, initialMessage: Message? = nil, - source: ChatForkSource? = nil, + source: ChatSource? = nil, workingDirectories: [String]? = nil, primaryWorkingDirectory: String? = nil ) { diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 3c9dacc61..816b6db2c 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -91,6 +91,8 @@ public enum ChatOriginKind: String, Codable, Sendable { case user = "user" /// Forked from an existing chat at a specific turn. case fork = "fork" + /// Created as an independent side conversation from a specific turn. + case sideChat = "sideChat" /// Spawned by a tool call running in another chat (e.g. a sub-agent delegation). case tool = "tool" } @@ -194,6 +196,8 @@ public enum MessageAttachmentKind: String, Codable, Sendable { case resource = "resource" /// An attachment that references annotations on an annotations channel. case annotations = "annotations" + /// An attachment that references a bounded transcript from another chat. + case chat = "chat" } /// Discriminant for response part types. @@ -630,7 +634,8 @@ public struct AgentCapabilities: Codable, Sendable { /// The agent can host more than one concurrent chat per session. When absent, /// clients MUST NOT call `createChat` to open chats beyond the default one the /// session starts with. An empty object `{}` advertises multi-chat without - /// forking; set {@link MultipleChatsCapability.fork} to also allow forking. + /// source-based creation; set {@link MultipleChatsCapability.fork} or + /// {@link MultipleChatsCapability.sideChat} to allow the corresponding mode. public var multipleChats: MultipleChatsCapability? /// The session's agent can be granted tool access to more than one working /// directory. The directories are treated as equal peers except where the @@ -653,14 +658,25 @@ public struct AgentCapabilities: Codable, Sendable { public struct MultipleChatsCapability: Codable, Sendable { /// The agent can fork a chat from a specific turn. When absent or `false`, - /// clients MUST NOT pass a {@link ChatForkSource} (`source`) to `createChat`. + /// clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to + /// `createChat`. /// Forking always implies multi-chat support. public var fork: Bool? + /// The agent can create a side chat from a specific turn. When absent or + /// `false`, clients MUST NOT pass a {@link ChatSource} with + /// `kind: "sideChat"` to `createChat`. + /// + /// A side chat receives the source turn as context without copying the source + /// transcript into its own visible history. Side-chat support always implies + /// multi-chat support. + public var sideChat: Bool? public init( - fork: Bool? = nil + fork: Bool? = nil, + sideChat: Bool? = nil ) { self.fork = fork + self.sideChat = sideChat } } @@ -2302,6 +2318,67 @@ public struct MessageAnnotationsAttachment: Codable, Sendable { } } +public struct MessageChatAttachment: Codable, Sendable { + /// A human-readable label for the attachment (e.g. the filename of a file + /// attachment). Used for display in UI. + public var label: String + /// If defined, the range in {@link Message.text} that references this + /// attachment. This is a text range, not a byte range. + public var range: TextRange? + /// Advisory display hint for clients rendering this attachment. Recognized + /// values include: + /// + /// - `'image'`: the attachment is an image + /// - `'document'`: the attachment is a textual document + /// - `'symbol'`: the attachment is a code symbol (e.g. a function or class) + /// - `'directory'`: the attachment is a folder + /// - `'selection'`: the attachment is a selection within a document + /// + /// Implementations MAY provide additional values; clients SHOULD fall back + /// to a reasonable default when an unknown value is encountered. + public var displayKind: String? + /// Additional implementation-defined metadata for the attachment. + /// + /// If the attachment was produced by the `completions` command, the client + /// MUST preserve every property of `_meta` originally returned by the agent + /// host when sending the user message containing the accepted completion. + public var meta: [String: AnyCodable]? + /// Discriminant + public var type: MessageAttachmentKind + /// URI of the referenced chat. + public var resource: String + /// Last completed turn included in the referenced transcript. + public var endTurn: String + + enum CodingKeys: String, CodingKey { + case label + case range + case displayKind + case meta = "_meta" + case type + case resource + case endTurn + } + + public init( + label: String, + range: TextRange? = nil, + displayKind: String? = nil, + meta: [String: AnyCodable]? = nil, + type: MessageAttachmentKind, + resource: String, + endTurn: String + ) { + self.label = label + self.range = range + self.displayKind = displayKind + self.meta = meta + self.type = type + self.resource = resource + self.endTurn = endTurn + } +} + public struct MarkdownResponsePart: Codable, Sendable { /// Discriminant public var kind: ResponsePartKind @@ -5151,9 +5228,22 @@ public struct ChatOriginTool: Codable, Sendable { } } +public struct ChatOriginSideChat: Codable, Sendable { + public var kind: ChatOriginKind + public var chat: String + public var turnId: String + + public init(kind: ChatOriginKind = .sideChat, chat: String, turnId: String) { + self.kind = kind + self.chat = chat + self.turnId = turnId + } +} + public enum ChatOrigin: Codable, Sendable { case user(ChatOriginUser) case fork(ChatOriginFork) + case sideChat(ChatOriginSideChat) case tool(ChatOriginTool) private enum DiscriminatorCodingKeys: String, CodingKey { case kind } @@ -5164,6 +5254,7 @@ public enum ChatOrigin: Codable, Sendable { switch discriminant { case "user": self = .user(try ChatOriginUser(from: decoder)) case "fork": self = .fork(try ChatOriginFork(from: decoder)) + case "sideChat": self = .sideChat(try ChatOriginSideChat(from: decoder)) case "tool": self = .tool(try ChatOriginTool(from: decoder)) default: throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown ChatOrigin kind: \(discriminant)") @@ -5174,6 +5265,7 @@ public enum ChatOrigin: Codable, Sendable { switch self { case .user(let value): try value.encode(to: encoder) case .fork(let value): try value.encode(to: encoder) + case .sideChat(let value): try value.encode(to: encoder) case .tool(let value): try value.encode(to: encoder) } } @@ -5516,6 +5608,7 @@ public enum MessageAttachment: Codable, Sendable { case embeddedResource(MessageEmbeddedResourceAttachment) case resource(MessageResourceAttachment) case annotations(MessageAnnotationsAttachment) + case chat(MessageChatAttachment) /// Unknown or future discriminant; the raw payload is preserved /// and re-encoded verbatim for forward-compatibility. case unknown(AnyCodable) @@ -5536,6 +5629,8 @@ public enum MessageAttachment: Codable, Sendable { self = .resource(try MessageResourceAttachment(from: decoder)) case "annotations": self = .annotations(try MessageAnnotationsAttachment(from: decoder)) + case "chat": + self = .chat(try MessageChatAttachment(from: decoder)) default: self = .unknown(try AnyCodable(from: decoder)) } @@ -5547,6 +5642,7 @@ public enum MessageAttachment: Codable, Sendable { case .embeddedResource(let value): try value.encode(to: encoder) case .resource(let value): try value.encode(to: encoder) case .annotations(let value): try value.encode(to: encoder) + case .chat(let value): try value.encode(to: encoder) case .unknown(let value): try value.encode(to: encoder) } } diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift index 88463cce5..47a9e481b 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift @@ -188,6 +188,8 @@ final class TypesRoundTripFixtureTests: XCTestCase { return try reencode(dec.decode(Implementation.self, from: inputData)) case "InitializeResult": return try reencode(dec.decode(InitializeResult.self, from: inputData)) + case "ChatSource": + return try reencode(dec.decode(ChatSource.self, from: inputData)) default: throw FixtureError.message( "round-trip fixture: unknown wire type \"\(type)\". Add a decode entry to decodeAndReencode.") diff --git a/clients/typescript/test/types-round-trip.test.ts b/clients/typescript/test/types-round-trip.test.ts index c3d2370f7..957c009ac 100644 --- a/clients/typescript/test/types-round-trip.test.ts +++ b/clients/typescript/test/types-round-trip.test.ts @@ -60,6 +60,7 @@ import type { } from '../src/types/channels-session/state.js'; import type { SessionAddedParams } from '../src/types/channels-root/notifications.js'; import type { Implementation, InitializeResult } from '../src/types/common/commands.js'; +import type { ChatSource } from '../src/types/channels-chat/commands.js'; // ─── Fixture directory ─────────────────────────────────────────────────────── @@ -242,6 +243,7 @@ function bindToType(file: string, type: string, parsed: unknown): void { case 'PartialSessionSummary': void (parsed as Partial); break; case 'Implementation': void (parsed as Implementation); break; case 'InitializeResult': void (parsed as InitializeResult); break; + case 'ChatSource': void (parsed as ChatSource); break; default: throw new Error( `${file}: unknown wire type "${type}". Add a decode entry to bindToType.`, diff --git a/docs/.changes/20260713-side-chats.json b/docs/.changes/20260713-side-chats.json new file mode 100644 index 000000000..5b6faa5a5 --- /dev/null +++ b/docs/.changes/20260713-side-chats.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "Capability-gated side chats can start from a chat turn and return bounded chat transcripts as message attachments." +} diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index 8b4753486..2a5eb33fa 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -659,8 +659,9 @@ createChat({ }); ``` -Forked chats (those with a `source`) inherit the source chat's -`workingDirectories` and primary; both fields are ignored for them. +Forked chats (`source.kind: "fork"`) inherit the source chat's +`workingDirectories` and primary, so both fields are ignored for forks. Side +chats can still choose their own subset and primary. #### Managing the subset after creation diff --git a/docs/proposals/multi-chat.md b/docs/proposals/multi-chat.md index f7577d6ac..6a9b12bf2 100644 --- a/docs/proposals/multi-chat.md +++ b/docs/proposals/multi-chat.md @@ -425,11 +425,26 @@ a `copilot` per piece of work; `/resume` reopens one at a time): (desktop app: N/A — terminal-only) ``` -### 8.2 Forking +### 8.2 Forking and side chats A new chat is forked from a point in an existing chat, seeded with that history, then diverges on its own. Both chats keep sharing the session's context. +A **side chat** starts from the same kind of `(chat, turn)` reference but does +not copy the parent's turns into its own transcript. It is a focused, +independent conversation that can later be pulled back into the main chat as a +bounded chat attachment. The distinction keeps the side transcript clean while +making the result durable and reusable: + +| Mode | Source context | New chat's visible history | Return path | +| --- | --- | --- | --- | +| Fork | Copied through the source turn | Starts with copied parent turns | Continue either branch | +| Side chat | Supplied through the source turn | Starts empty | Attach through a completed side-chat turn | + +Agents advertise these independently through `multipleChats.fork` and +`multipleChats.sideChat`, so clients only offer creation modes the selected +agent supports. + > **Real-world example:** Mid-debugging, at message 12 the agent proposes two > fixes for a race condition. Rather than lose the current thread, the developer > forks a new chat *seeded from message 12* to try approach B (rewrite the path diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index c326c7682..3874e9b42 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -28,7 +28,8 @@ 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). +example, a fork, side-chat source, chat attachment, or truncation targeting an +older turn). ### Drafts @@ -50,7 +51,7 @@ Clients MAY periodically sync their local input state into the draft by dispatch ``` 1. Client subscribes to the owning session URI (ahp-session:/) -2. Client (or the server, via a tool call or fork) creates a chat with createChat +2. Client (or the server, via a tool call, fork, or side chat) creates a chat with createChat 3. Server allocates a chat URI (ahp-chat:/) and mutates the session's chats catalog 4. Client subscribes to the chat URI to receive its ChatState snapshot 5. Server streams chat actions over the chat channel until the chat (or its session) is disposed @@ -61,10 +62,30 @@ Clients MAY periodically sync their local input state into the draft by dispatch [`createChat`](/reference/chat#createchat) is a JSON-RPC request. Callers identify the owning session via the request's `channel` parameter (`ahp-session:/`) and MAY supply: - an `initialMessage` to start the first turn immediately — carrying its own [`model`](/reference/chat#message) / [`agent`](/reference/chat#message) selection — and -- a `source` of type [`ChatForkSource`](/reference/chat#chatforksource) to fork from an existing chat at a specific turn. +- a `source` of type [`ChatSource`](/reference/chat#chatsource), whose required `kind` selects either a `fork` or `sideChat` from an existing chat at a specific completed turn. The server allocates the chat URI and adds the chat to the session's catalog (`session/chatAdded` on the session channel) before returning. +Clients MUST gate source-based creation using the selected +[`AgentInfo.capabilities.multipleChats`](/reference/root#multiplechatscapability): + +- `fork: true` permits `source.kind: "fork"`. +- `sideChat: true` permits `source.kind: "sideChat"`. + +Absence or `false` means the corresponding source kind is unsupported. The host +MUST reject an unsupported source kind. It MUST also reject a source chat outside +the target session, an unknown source chat or turn, an active rather than +completed source turn, or a source that names the chat being created. + +Forks and side chats use the source differently: + +- A **fork** copies source history through the referenced turn into the new + chat's visible `turns`, after which the chats diverge. +- A **side chat** starts with its own empty visible history. The host supplies + source history through the referenced turn as agent context, but does not copy + that history into the side chat's `turns`. An `initialMessage`, when supplied, + becomes the side chat's first visible turn. + ### Origin Each chat advertises how it came into existence via [`ChatOrigin`](/reference/chat#chatorigin): @@ -73,6 +94,7 @@ Each chat advertises how it came into existence via [`ChatOrigin`](/reference/ch |---|---| | `user` | User created the chat explicitly (e.g. via the host UI). | | `fork` | Forked from an existing chat at a specific turn — payload references the source chat URI and turn id. | +| `sideChat` | Created as an independent side conversation using context through a specific source turn — payload references the source chat URI and turn id. | | `tool` | Spawned by a tool call running in another chat — payload references the source chat URI and tool call id (e.g. a sub-agent delegation). | Clients MAY use the origin to render contextual UI (parent indicators, fork markers, "spawned by tool" badges), but origin is **not** a hierarchy — every chat is equally addressable. @@ -81,11 +103,37 @@ A tool-spawned worker is described from both ends of the same edge. The worker c #### Ancestry and nesting depth -A `fork` or `tool` origin names only the chat's **immediate** source chat (by URI), together with the turn or tool call that produced it. A chat's ancestry is therefore not stored directly; it is the chain you reconstruct by following `origin.chat` from one chat to the next. Because a tool-spawned chat can itself run tools that spawn further chats, these chains can be arbitrarily deep. +A `fork`, `sideChat`, or `tool` origin names only the chat's **immediate** source chat (by URI), together with the turn or tool call that produced it. A chat's ancestry is therefore not stored directly; it is the chain you reconstruct by following `origin.chat` from one chat to the next. Because a tool-spawned chat can itself run tools that spawn further chats, these chains can be arbitrarily deep. - **No protocol-imposed depth limit.** AHP does not cap nesting depth or fan-out, and the wire carries no depth counter or maximum-depth field. Any bound is a host policy decision that the protocol neither enforces nor advertises; hosts SHOULD guard against runaway recursion or unbounded fan-out on their side. - **Ancestry is advisory and may be incomplete.** Every chat is a flat, equally-addressable peer in the session's [`chats`](/reference/session#sessionstate) catalog — `origin` is a rendering hint, not a structural parent link. A source chat MAY be pruned (`session/chatRemoved`) while a chat it spawned lives on, so an `origin.chat` URI is not guaranteed to resolve. Clients reconstructing ancestry MUST tolerate missing references and SHOULD guard against cycles and unbounded depth (for example, by capping how deep they walk or render). +### Pulling a chat into another chat + +A message can attach a bounded transcript using a +[`MessageChatAttachment`](/reference/chat#messagechatattachment). Its `resource` +identifies another chat in the same session and `endTurn` identifies the last +completed turn included in the transcript. The bound is required: later +turns in the referenced chat MUST NOT retroactively change the context of an +already-sent message. + +This is the standard way to pull a side-chat result back into its originating +chat. It is not limited to that UX: any chat may attach another chat from the +same session. No merge or chat-to-chat messaging action occurs; the attachment +travels on the ordinary message in `chat/turnStarted`. + +When accepting the message, the host MUST resolve the referenced chat's retained +transcript from its first turn through `endTurn`, inclusive, and supply it +as model context. The host MUST reject an unknown chat, a cross-session chat, an +unknown turn, or an active rather than completed turn. Chat attachments inside +the referenced transcript MUST remain references and MUST NOT be recursively +expanded, preventing cycles and unbounded context growth. + +The attachment itself remains durable turn state. If the referenced chat is +later pruned, clients SHOULD continue rendering the stored `label` and treat +opening `resource` as best-effort. Pruning does not alter the model input that +the host already materialized when it accepted the containing message. + ### Active chat Once a chat exists and its session is `lifecycle: 'ready'`, the chat accepts turns. The wire shape mirrors the legacy single-chat session shape: diff --git a/schema/actions.schema.json b/schema/actions.schema.json index eebb5e579..b4befa031 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -2717,7 +2717,7 @@ "properties": { "multipleChats": { "$ref": "#/$defs/MultipleChatsCapability", - "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nforking; set {@link MultipleChatsCapability.fork} to also allow forking." + "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nsource-based creation; set {@link MultipleChatsCapability.fork} or\n{@link MultipleChatsCapability.sideChat} to allow the corresponding mode." }, "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", @@ -2731,7 +2731,11 @@ "properties": { "fork": { "type": "boolean", - "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatForkSource} (`source`) to `createChat`.\nForking always implies multi-chat support." + "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatSource} with `kind: \"fork\"` to\n`createChat`.\nForking always implies multi-chat support." + }, + "sideChat": { + "type": "boolean", + "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. Side-chat support always implies\nmulti-chat support." } } }, @@ -5393,6 +5397,47 @@ "resource" ] }, + "MessageChatAttachment": { + "type": "object", + "description": "An attachment that references a chat transcript through a fixed completed\nturn.\n\nThe referenced chat MUST belong to the same session as the message's chat.\nThe host resolves the transcript from its first retained turn through\n`endTurn`, inclusive, when accepting the message. Later turns do not\nchange the context represented by an already-sent attachment.\n\nHosts MUST NOT recursively expand chat attachments found inside the\nreferenced transcript. Clients SHOULD keep rendering `label` if the\nreferenced chat is later pruned, and treat opening `resource` as best-effort.", + "properties": { + "label": { + "type": "string", + "description": "A human-readable label for the attachment (e.g. the filename of a file\nattachment). Used for display in UI." + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "If defined, the range in {@link Message.text} that references this\nattachment. This is a text range, not a byte range." + }, + "displayKind": { + "type": "string", + "description": "Advisory display hint for clients rendering this attachment. Recognized\nvalues include:\n\n- `'image'`: the attachment is an image\n- `'document'`: the attachment is a textual document\n- `'symbol'`: the attachment is a code symbol (e.g. a function or class)\n- `'directory'`: the attachment is a folder\n- `'selection'`: the attachment is a selection within a document\n\nImplementations MAY provide additional values; clients SHOULD fall back\nto a reasonable default when an unknown value is encountered." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Additional implementation-defined metadata for the attachment.\n\nIf the attachment was produced by the `completions` command, the client\nMUST preserve every property of `_meta` originally returned by the agent\nhost when sending the user message containing the accepted completion." + }, + "type": { + "const": "chat", + "description": "Discriminant" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "URI of the referenced chat." + }, + "endTurn": { + "type": "string", + "description": "Last completed turn included in the referenced transcript." + } + }, + "required": [ + "label", + "type", + "resource", + "endTurn" + ] + }, "MarkdownResponsePart": { "type": "object", "properties": { @@ -7163,7 +7208,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "user" } }, "required": [ @@ -7174,7 +7219,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "fork" }, "chat": { "type": "string" @@ -7193,7 +7238,26 @@ "type": "object", "properties": { "kind": { + "const": "sideChat" + }, + "chat": { + "type": "string" + }, + "turnId": { "type": "string" + } + }, + "required": [ + "kind", + "chat", + "turnId" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "tool" }, "chat": { "type": "string" @@ -7274,6 +7338,9 @@ }, { "$ref": "#/$defs/MessageAnnotationsAttachment" + }, + { + "$ref": "#/$defs/MessageChatAttachment" } ], "description": "An attachment associated with a {@link Message}." diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 231e924a1..8ab2742ca 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -1100,20 +1100,25 @@ "items" ] }, - "ChatForkSource": { + "ChatSource": { "type": "object", - "description": "Identifies a source chat and turn to fork from.", + "description": "Identifies a source chat and completed turn for a new chat.", "properties": { + "kind": { + "$ref": "#/$defs/ChatSourceKind", + "description": "How the source is used." + }, "chat": { "$ref": "#/$defs/URI", - "description": "URI of the existing chat to fork from" + "description": "URI of the existing source chat." }, "turnId": { "type": "string", - "description": "Turn ID in the source chat; content up to and including this turn's response is copied" + "description": "Completed turn in the source chat. For a fork, content through this turn is\ncopied. For a side chat, that content is supplied as context but is not\ncopied into the new chat's visible `turns`." } }, "required": [ + "kind", "chat", "turnId" ] @@ -1135,19 +1140,19 @@ "description": "Optional initial message for the new chat." }, "source": { - "$ref": "#/$defs/ChatForkSource", - "description": "Optional source chat and turn to fork from." + "$ref": "#/$defs/ChatSource", + "description": "Optional source chat and completed turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`." }, "workingDirectories": { "type": "array", "items": { "$ref": "#/$defs/URI" }, - "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (`source`) inherit the source chat's\n`workingDirectories`; this field is ignored for forked chats.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." + "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (`source.kind === \"fork\"`) inherit the source\nchat's `workingDirectories`; this field is ignored for forks.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." }, "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on. When set, it MUST be one of the chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nomitted). A client SHOULD supply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first of the chat's\ndirectories. Fixed at creation and reported (read-only) on\n{@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork\ninherits the source chat's primary)." + "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on. When set, it MUST be one of the chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nomitted). A client SHOULD supply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first of the chat's\ndirectories. Fixed at creation and reported (read-only) on\n{@link ChatState.primaryWorkingDirectory}. Ignored for forks (a\n`source.kind === \"fork\"` chat inherits the source chat's primary)." } }, "required": [ @@ -1860,7 +1865,7 @@ "properties": { "multipleChats": { "$ref": "#/$defs/MultipleChatsCapability", - "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nforking; set {@link MultipleChatsCapability.fork} to also allow forking." + "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nsource-based creation; set {@link MultipleChatsCapability.fork} or\n{@link MultipleChatsCapability.sideChat} to allow the corresponding mode." }, "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", @@ -1874,7 +1879,11 @@ "properties": { "fork": { "type": "boolean", - "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatForkSource} (`source`) to `createChat`.\nForking always implies multi-chat support." + "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatSource} with `kind: \"fork\"` to\n`createChat`.\nForking always implies multi-chat support." + }, + "sideChat": { + "type": "boolean", + "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. Side-chat support always implies\nmulti-chat support." } } }, @@ -4536,6 +4545,47 @@ "resource" ] }, + "MessageChatAttachment": { + "type": "object", + "description": "An attachment that references a chat transcript through a fixed completed\nturn.\n\nThe referenced chat MUST belong to the same session as the message's chat.\nThe host resolves the transcript from its first retained turn through\n`endTurn`, inclusive, when accepting the message. Later turns do not\nchange the context represented by an already-sent attachment.\n\nHosts MUST NOT recursively expand chat attachments found inside the\nreferenced transcript. Clients SHOULD keep rendering `label` if the\nreferenced chat is later pruned, and treat opening `resource` as best-effort.", + "properties": { + "label": { + "type": "string", + "description": "A human-readable label for the attachment (e.g. the filename of a file\nattachment). Used for display in UI." + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "If defined, the range in {@link Message.text} that references this\nattachment. This is a text range, not a byte range." + }, + "displayKind": { + "type": "string", + "description": "Advisory display hint for clients rendering this attachment. Recognized\nvalues include:\n\n- `'image'`: the attachment is an image\n- `'document'`: the attachment is a textual document\n- `'symbol'`: the attachment is a code symbol (e.g. a function or class)\n- `'directory'`: the attachment is a folder\n- `'selection'`: the attachment is a selection within a document\n\nImplementations MAY provide additional values; clients SHOULD fall back\nto a reasonable default when an unknown value is encountered." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Additional implementation-defined metadata for the attachment.\n\nIf the attachment was produced by the `completions` command, the client\nMUST preserve every property of `_meta` originally returned by the agent\nhost when sending the user message containing the accepted completion." + }, + "type": { + "const": "chat", + "description": "Discriminant" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "URI of the referenced chat." + }, + "endTurn": { + "type": "string", + "description": "Last completed turn included in the referenced transcript." + } + }, + "required": [ + "label", + "type", + "resource", + "endTurn" + ] + }, "MarkdownResponsePart": { "type": "object", "properties": { @@ -8538,10 +8588,21 @@ }, { "$ref": "#/$defs/MessageAnnotationsAttachment" + }, + { + "$ref": "#/$defs/MessageChatAttachment" } ], "description": "An attachment associated with a {@link Message}." }, + "ChatSourceKind": { + "enum": [ + "fork", + "sideChat" + ], + "type": "string", + "description": "How a new chat uses its source chat and turn." + }, "TerminalClaim": { "oneOf": [ { @@ -8559,7 +8620,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "resource" }, "resource": { "type": "string" @@ -8577,7 +8638,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "range" }, "resource": { "type": "string" @@ -8830,7 +8891,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "user" } }, "required": [ @@ -8841,7 +8902,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "fork" }, "chat": { "type": "string" @@ -8860,7 +8921,26 @@ "type": "object", "properties": { "kind": { + "const": "sideChat" + }, + "chat": { + "type": "string" + }, + "turnId": { "type": "string" + } + }, + "required": [ + "kind", + "chat", + "turnId" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "tool" }, "chat": { "type": "string" diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 5504cfcbd..cc1b12b2b 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -626,7 +626,7 @@ "properties": { "multipleChats": { "$ref": "#/$defs/MultipleChatsCapability", - "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nforking; set {@link MultipleChatsCapability.fork} to also allow forking." + "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nsource-based creation; set {@link MultipleChatsCapability.fork} or\n{@link MultipleChatsCapability.sideChat} to allow the corresponding mode." }, "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", @@ -640,7 +640,11 @@ "properties": { "fork": { "type": "boolean", - "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatForkSource} (`source`) to `createChat`.\nForking always implies multi-chat support." + "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatSource} with `kind: \"fork\"` to\n`createChat`.\nForking always implies multi-chat support." + }, + "sideChat": { + "type": "boolean", + "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. Side-chat support always implies\nmulti-chat support." } } }, @@ -3302,6 +3306,47 @@ "resource" ] }, + "MessageChatAttachment": { + "type": "object", + "description": "An attachment that references a chat transcript through a fixed completed\nturn.\n\nThe referenced chat MUST belong to the same session as the message's chat.\nThe host resolves the transcript from its first retained turn through\n`endTurn`, inclusive, when accepting the message. Later turns do not\nchange the context represented by an already-sent attachment.\n\nHosts MUST NOT recursively expand chat attachments found inside the\nreferenced transcript. Clients SHOULD keep rendering `label` if the\nreferenced chat is later pruned, and treat opening `resource` as best-effort.", + "properties": { + "label": { + "type": "string", + "description": "A human-readable label for the attachment (e.g. the filename of a file\nattachment). Used for display in UI." + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "If defined, the range in {@link Message.text} that references this\nattachment. This is a text range, not a byte range." + }, + "displayKind": { + "type": "string", + "description": "Advisory display hint for clients rendering this attachment. Recognized\nvalues include:\n\n- `'image'`: the attachment is an image\n- `'document'`: the attachment is a textual document\n- `'symbol'`: the attachment is a code symbol (e.g. a function or class)\n- `'directory'`: the attachment is a folder\n- `'selection'`: the attachment is a selection within a document\n\nImplementations MAY provide additional values; clients SHOULD fall back\nto a reasonable default when an unknown value is encountered." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Additional implementation-defined metadata for the attachment.\n\nIf the attachment was produced by the `completions` command, the client\nMUST preserve every property of `_meta` originally returned by the agent\nhost when sending the user message containing the accepted completion." + }, + "type": { + "const": "chat", + "description": "Discriminant" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "URI of the referenced chat." + }, + "endTurn": { + "type": "string", + "description": "Last completed turn included in the referenced transcript." + } + }, + "required": [ + "label", + "type", + "resource", + "endTurn" + ] + }, "MarkdownResponsePart": { "type": "object", "properties": { @@ -6011,20 +6056,25 @@ "items" ] }, - "ChatForkSource": { + "ChatSource": { "type": "object", - "description": "Identifies a source chat and turn to fork from.", + "description": "Identifies a source chat and completed turn for a new chat.", "properties": { + "kind": { + "$ref": "#/$defs/ChatSourceKind", + "description": "How the source is used." + }, "chat": { "$ref": "#/$defs/URI", - "description": "URI of the existing chat to fork from" + "description": "URI of the existing source chat." }, "turnId": { "type": "string", - "description": "Turn ID in the source chat; content up to and including this turn's response is copied" + "description": "Completed turn in the source chat. For a fork, content through this turn is\ncopied. For a side chat, that content is supplied as context but is not\ncopied into the new chat's visible `turns`." } }, "required": [ + "kind", "chat", "turnId" ] @@ -6046,19 +6096,19 @@ "description": "Optional initial message for the new chat." }, "source": { - "$ref": "#/$defs/ChatForkSource", - "description": "Optional source chat and turn to fork from." + "$ref": "#/$defs/ChatSource", + "description": "Optional source chat and completed turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`." }, "workingDirectories": { "type": "array", "items": { "$ref": "#/$defs/URI" }, - "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (`source`) inherit the source chat's\n`workingDirectories`; this field is ignored for forked chats.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." + "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (`source.kind === \"fork\"`) inherit the source\nchat's `workingDirectories`; this field is ignored for forks.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." }, "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on. When set, it MUST be one of the chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nomitted). A client SHOULD supply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first of the chat's\ndirectories. Fixed at creation and reported (read-only) on\n{@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork\ninherits the source chat's primary)." + "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on. When set, it MUST be one of the chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nomitted). A client SHOULD supply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first of the chat's\ndirectories. Fixed at creation and reported (read-only) on\n{@link ChatState.primaryWorkingDirectory}. Ignored for forks (a\n`source.kind === \"fork\"` chat inherits the source chat's primary)." } }, "required": [ @@ -6456,7 +6506,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "user" } }, "required": [ @@ -6467,7 +6517,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "fork" }, "chat": { "type": "string" @@ -6486,8 +6536,27 @@ "type": "object", "properties": { "kind": { + "const": "sideChat" + }, + "chat": { "type": "string" }, + "turnId": { + "type": "string" + } + }, + "required": [ + "kind", + "chat", + "turnId" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "tool" + }, "chat": { "type": "string" }, @@ -6617,6 +6686,9 @@ }, { "$ref": "#/$defs/MessageAnnotationsAttachment" + }, + { + "$ref": "#/$defs/MessageChatAttachment" } ], "description": "An attachment associated with a {@link Message}." @@ -7108,13 +7180,21 @@ "type": "string", "description": "The kind of completion items being requested." }, + "ChatSourceKind": { + "enum": [ + "fork", + "sideChat" + ], + "type": "string", + "description": "How a new chat uses its source chat and turn." + }, "ChangesetOperationTarget": { "oneOf": [ { "type": "object", "properties": { "kind": { - "type": "string" + "const": "resource" }, "resource": { "type": "string" @@ -7132,7 +7212,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "range" }, "resource": { "type": "string" diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 2319bd5c5..220c64351 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -789,7 +789,7 @@ "properties": { "multipleChats": { "$ref": "#/$defs/MultipleChatsCapability", - "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nforking; set {@link MultipleChatsCapability.fork} to also allow forking." + "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nsource-based creation; set {@link MultipleChatsCapability.fork} or\n{@link MultipleChatsCapability.sideChat} to allow the corresponding mode." }, "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", @@ -803,7 +803,11 @@ "properties": { "fork": { "type": "boolean", - "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatForkSource} (`source`) to `createChat`.\nForking always implies multi-chat support." + "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatSource} with `kind: \"fork\"` to\n`createChat`.\nForking always implies multi-chat support." + }, + "sideChat": { + "type": "boolean", + "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. Side-chat support always implies\nmulti-chat support." } } }, @@ -3465,6 +3469,47 @@ "resource" ] }, + "MessageChatAttachment": { + "type": "object", + "description": "An attachment that references a chat transcript through a fixed completed\nturn.\n\nThe referenced chat MUST belong to the same session as the message's chat.\nThe host resolves the transcript from its first retained turn through\n`endTurn`, inclusive, when accepting the message. Later turns do not\nchange the context represented by an already-sent attachment.\n\nHosts MUST NOT recursively expand chat attachments found inside the\nreferenced transcript. Clients SHOULD keep rendering `label` if the\nreferenced chat is later pruned, and treat opening `resource` as best-effort.", + "properties": { + "label": { + "type": "string", + "description": "A human-readable label for the attachment (e.g. the filename of a file\nattachment). Used for display in UI." + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "If defined, the range in {@link Message.text} that references this\nattachment. This is a text range, not a byte range." + }, + "displayKind": { + "type": "string", + "description": "Advisory display hint for clients rendering this attachment. Recognized\nvalues include:\n\n- `'image'`: the attachment is an image\n- `'document'`: the attachment is a textual document\n- `'symbol'`: the attachment is a code symbol (e.g. a function or class)\n- `'directory'`: the attachment is a folder\n- `'selection'`: the attachment is a selection within a document\n\nImplementations MAY provide additional values; clients SHOULD fall back\nto a reasonable default when an unknown value is encountered." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Additional implementation-defined metadata for the attachment.\n\nIf the attachment was produced by the `completions` command, the client\nMUST preserve every property of `_meta` originally returned by the agent\nhost when sending the user message containing the accepted completion." + }, + "type": { + "const": "chat", + "description": "Discriminant" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "URI of the referenced chat." + }, + "endTurn": { + "type": "string", + "description": "Last completed turn included in the referenced transcript." + } + }, + "required": [ + "label", + "type", + "resource", + "endTurn" + ] + }, "MarkdownResponsePart": { "type": "object", "properties": { @@ -5304,7 +5349,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "user" } }, "required": [ @@ -5315,7 +5360,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "fork" }, "chat": { "type": "string" @@ -5334,7 +5379,26 @@ "type": "object", "properties": { "kind": { + "const": "sideChat" + }, + "chat": { + "type": "string" + }, + "turnId": { "type": "string" + } + }, + "required": [ + "kind", + "chat", + "turnId" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "tool" }, "chat": { "type": "string" @@ -5465,6 +5529,9 @@ }, { "$ref": "#/$defs/MessageAnnotationsAttachment" + }, + { + "$ref": "#/$defs/MessageChatAttachment" } ], "description": "An attachment associated with a {@link Message}." diff --git a/schema/state.schema.json b/schema/state.schema.json index f82a33c76..7f5268b77 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -537,7 +537,7 @@ "properties": { "multipleChats": { "$ref": "#/$defs/MultipleChatsCapability", - "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nforking; set {@link MultipleChatsCapability.fork} to also allow forking." + "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nsource-based creation; set {@link MultipleChatsCapability.fork} or\n{@link MultipleChatsCapability.sideChat} to allow the corresponding mode." }, "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", @@ -551,7 +551,11 @@ "properties": { "fork": { "type": "boolean", - "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatForkSource} (`source`) to `createChat`.\nForking always implies multi-chat support." + "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatSource} with `kind: \"fork\"` to\n`createChat`.\nForking always implies multi-chat support." + }, + "sideChat": { + "type": "boolean", + "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. Side-chat support always implies\nmulti-chat support." } } }, @@ -3213,6 +3217,47 @@ "resource" ] }, + "MessageChatAttachment": { + "type": "object", + "description": "An attachment that references a chat transcript through a fixed completed\nturn.\n\nThe referenced chat MUST belong to the same session as the message's chat.\nThe host resolves the transcript from its first retained turn through\n`endTurn`, inclusive, when accepting the message. Later turns do not\nchange the context represented by an already-sent attachment.\n\nHosts MUST NOT recursively expand chat attachments found inside the\nreferenced transcript. Clients SHOULD keep rendering `label` if the\nreferenced chat is later pruned, and treat opening `resource` as best-effort.", + "properties": { + "label": { + "type": "string", + "description": "A human-readable label for the attachment (e.g. the filename of a file\nattachment). Used for display in UI." + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "If defined, the range in {@link Message.text} that references this\nattachment. This is a text range, not a byte range." + }, + "displayKind": { + "type": "string", + "description": "Advisory display hint for clients rendering this attachment. Recognized\nvalues include:\n\n- `'image'`: the attachment is an image\n- `'document'`: the attachment is a textual document\n- `'symbol'`: the attachment is a code symbol (e.g. a function or class)\n- `'directory'`: the attachment is a folder\n- `'selection'`: the attachment is a selection within a document\n\nImplementations MAY provide additional values; clients SHOULD fall back\nto a reasonable default when an unknown value is encountered." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Additional implementation-defined metadata for the attachment.\n\nIf the attachment was produced by the `completions` command, the client\nMUST preserve every property of `_meta` originally returned by the agent\nhost when sending the user message containing the accepted completion." + }, + "type": { + "const": "chat", + "description": "Discriminant" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "URI of the referenced chat." + }, + "endTurn": { + "type": "string", + "description": "Last completed turn included in the referenced transcript." + } + }, + "required": [ + "label", + "type", + "resource", + "endTurn" + ] + }, "MarkdownResponsePart": { "type": "object", "properties": { @@ -4983,7 +5028,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "user" } }, "required": [ @@ -4994,7 +5039,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "fork" }, "chat": { "type": "string" @@ -5013,7 +5058,26 @@ "type": "object", "properties": { "kind": { + "const": "sideChat" + }, + "chat": { + "type": "string" + }, + "turnId": { "type": "string" + } + }, + "required": [ + "kind", + "chat", + "turnId" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "tool" }, "chat": { "type": "string" @@ -5094,6 +5158,9 @@ }, { "$ref": "#/$defs/MessageAnnotationsAttachment" + }, + { + "$ref": "#/$defs/MessageChatAttachment" } ], "description": "An attachment associated with a {@link Message}." diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 7a5c0e26a..b5b86c464 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -707,6 +707,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'MessageEmbeddedResourceAttachment' }, { name: 'MessageResourceAttachment' }, { name: 'MessageAnnotationsAttachment' }, + { name: 'MessageChatAttachment' }, { name: 'MarkdownResponsePart' }, { name: 'ContentRef' }, { name: 'ResourceReponsePart', goName: 'ResourceResponsePart' }, @@ -912,6 +913,7 @@ const MESSAGE_ATTACHMENT_UNION: UnionConfig = { { variantName: 'EmbeddedResource', innerType: 'MessageEmbeddedResourceAttachment', wireValue: 'embeddedResource' }, { variantName: 'Resource', innerType: 'MessageResourceAttachment', wireValue: 'resource' }, { variantName: 'Annotations', innerType: 'MessageAnnotationsAttachment', wireValue: 'annotations' }, + { variantName: 'Chat', innerType: 'MessageChatAttachment', wireValue: 'chat' }, ], unknown: true, }; @@ -1028,6 +1030,14 @@ type ChatForkOrigin struct { func (*ChatForkOrigin) isChatOrigin() {} +type ChatSideChatOrigin struct { +\tKind ChatOriginKind \`json:"kind"\` +\tChat URI \`json:"chat"\` +\tTurnId string \`json:"turnId"\` +} + +func (*ChatSideChatOrigin) isChatOrigin() {} + type ChatToolOrigin struct { \tKind ChatOriginKind \`json:"kind"\` \tChat URI \`json:"chat"\` @@ -1060,6 +1070,12 @@ func (o *ChatOrigin) UnmarshalJSON(data []byte) error { \t\t\treturn err \t\t} \t\to.Value = &v +\tcase "sideChat": +\t\tvar v ChatSideChatOrigin +\t\tif err := json.Unmarshal(data, &v); err != nil { +\t\t\treturn err +\t\t} +\t\to.Value = &v \tcase "tool": \t\tvar v ChatToolOrigin \t\tif err := json.Unmarshal(data, &v); err != nil { @@ -1454,7 +1470,7 @@ function generateActionsFile(project: Project): string { // ─── Commands File Generator ───────────────────────────────────────────────── -const COMMAND_ENUMS = ['ReconnectResultType', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode']; +const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode']; const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: string }[] = [ { name: 'InitializeParams' }, { name: 'InitializeResult' }, @@ -1465,7 +1481,7 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: str { name: 'SubscribeParams' }, { name: 'SubscribeView' }, { name: 'SubscriptionDeliveryOptions' }, { name: 'SubscribeResult' }, { name: 'SessionForkSource' }, { name: 'CreateSessionParams' }, { name: 'DisposeSessionParams' }, - { name: 'ChatForkSource' }, { name: 'CreateChatParams' }, { name: 'DisposeChatParams' }, + { name: 'ChatSource' }, { name: 'CreateChatParams' }, { name: 'DisposeChatParams' }, { name: 'ListSessionsParams' }, { name: 'ListSessionsResult' }, { name: 'ResourceReadParams' }, { name: 'ResourceReadResult' }, { name: 'ResourceWriteParams' }, { name: 'ResourceWriteResult' }, diff --git a/scripts/generate-json-schema.test.ts b/scripts/generate-json-schema.test.ts index 19c3aaffc..7f3871604 100644 --- a/scripts/generate-json-schema.test.ts +++ b/scripts/generate-json-schema.test.ts @@ -90,6 +90,18 @@ describe('generated JSON schemas', () => { `${file} references ${dangling.length} undefined $def(s) (bug #302.2): ${dangling.slice(0, 10).join(', ')}`, ); }); + + it('constrains every ChatOrigin branch to a distinct kind', () => { + const defs = schema.$defs as Record>; + const chatOrigin = defs.ChatOrigin; + const branches = chatOrigin.oneOf as Array>; + const kinds = branches.map((branch) => { + const properties = branch.properties as Record>; + return properties.kind.const; + }); + + assert.deepEqual(kinds, ['user', 'fork', 'sideChat', 'tool']); + }); }); } }); diff --git a/scripts/generate-json-schema.ts b/scripts/generate-json-schema.ts index e8ebf457d..9566851f0 100644 --- a/scripts/generate-json-schema.ts +++ b/scripts/generate-json-schema.ts @@ -214,20 +214,14 @@ function typeTextToSchema(typeText: string, project: Project, _depth = 0): JsonS // value as a `const`. Without this the capitalized-identifier fallback below // emits a `$ref` to `#/$defs/ActionType.ChatTurnStarted` that is never // defined (dangling). Must run before the interface-reference fallback. - const enumMemberMatch = cleaned.match(/^([A-Z]\w*)\.(\w+)$/); - if (enumMemberMatch) { - const [, enumName, memberName] = enumMemberMatch; - const en = findEnum(project, enumName); - const member = en?.getMember(memberName); - const value = member?.getValue(); - if (value !== undefined) { - return { const: value }; - } + const enumMemberSchema = enumMemberToSchema(cleaned, project); + if (enumMemberSchema) { + return enumMemberSchema; } // Inline object: { message: string; code?: string } if (cleaned.startsWith('{') && cleaned.endsWith('}')) { - return inlineObjectToSchema(cleaned); + return inlineObjectToSchema(cleaned, project); } // Interface references: check if it's a known interface @@ -256,7 +250,16 @@ function splitUnionType(typeText: string): string[] { return parts; } -function inlineObjectToSchema(text: string): JsonSchema { +function enumMemberToSchema(typeText: string, project: Project): JsonSchema | undefined { + const match = typeText.match(/^([A-Z]\w*)\.(\w+)$/); + if (!match) return undefined; + + const [, enumName, memberName] = match; + const value = findEnum(project, enumName)?.getMember(memberName)?.getValue(); + return value === undefined ? undefined : { const: value }; +} + +function inlineObjectToSchema(text: string, project: Project): JsonSchema { // Parse { key: type; key?: type } style, stripping any JSDoc/block comments const cleaned = text.replace(/\/\*[\s\S]*?\*\//g, ''); const inner = cleaned.slice(cleaned.indexOf('{') + 1, cleaned.lastIndexOf('}')).trim(); @@ -267,7 +270,9 @@ function inlineObjectToSchema(text: string): JsonSchema { const match = field.match(/^(\w+)(\?)?:\s*(.+)$/); if (match) { const [, name, optional, type] = match; - schema.properties![name] = { type: mapSimpleType(type.trim()) }; + const fieldType = type.trim(); + schema.properties![name] = + enumMemberToSchema(fieldType, project) ?? { type: mapSimpleType(fieldType) }; if (!optional) { schema.required!.push(name); } diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 2cf0cd87d..bb6206eda 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -822,7 +822,7 @@ const STATE_STRUCTS = [ 'ChatInputRequest', 'TextPosition', 'TextRange', 'TextSelection', 'SimpleMessageAttachment', 'MessageEmbeddedResourceAttachment', 'MessageResourceAttachment', - 'MessageAnnotationsAttachment', + 'MessageAnnotationsAttachment', 'MessageChatAttachment', 'MarkdownResponsePart', 'ContentRef', 'ResourceReponsePart', 'ToolCallResponsePart', 'ReasoningResponsePart', 'SystemNotificationResponsePart', 'InputRequestResponsePart', @@ -919,6 +919,7 @@ function generateChatOriginKotlin(): string { sealed interface ChatOrigin { @JvmInline value class User(val value: ChatOriginUser) : ChatOrigin @JvmInline value class Fork(val value: ChatOriginFork) : ChatOrigin + @JvmInline value class SideChat(val value: ChatOriginSideChat) : ChatOrigin @JvmInline value class Tool(val value: ChatOriginTool) : ChatOrigin @JvmInline value class Unknown(val raw: JsonObject) : ChatOrigin } @@ -942,6 +943,13 @@ data class ChatOriginTool( val toolCallId: String, ) +@Serializable +data class ChatOriginSideChat( + val kind: ChatOriginKind = ChatOriginKind.SIDE_CHAT, + val chat: String, + val turnId: String, +) + internal object ChatOriginSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ChatOrigin") @@ -952,6 +960,7 @@ internal object ChatOriginSerializer : KSerializer { return when ((obj["kind"] as? JsonPrimitive)?.contentOrNull) { "user" -> ChatOrigin.User(input.json.decodeFromJsonElement(ChatOriginUser.serializer(), element)) "fork" -> ChatOrigin.Fork(input.json.decodeFromJsonElement(ChatOriginFork.serializer(), element)) + "sideChat" -> ChatOrigin.SideChat(input.json.decodeFromJsonElement(ChatOriginSideChat.serializer(), element)) "tool" -> ChatOrigin.Tool(input.json.decodeFromJsonElement(ChatOriginTool.serializer(), element)) else -> ChatOrigin.Unknown(obj) } @@ -962,6 +971,7 @@ internal object ChatOriginSerializer : KSerializer { val element: JsonElement = when (value) { is ChatOrigin.User -> output.json.encodeToJsonElement(ChatOriginUser.serializer(), value.value) is ChatOrigin.Fork -> output.json.encodeToJsonElement(ChatOriginFork.serializer(), value.value) + is ChatOrigin.SideChat -> output.json.encodeToJsonElement(ChatOriginSideChat.serializer(), value.value) is ChatOrigin.Tool -> output.json.encodeToJsonElement(ChatOriginTool.serializer(), value.value) is ChatOrigin.Unknown -> value.raw } @@ -1018,6 +1028,7 @@ const MESSAGE_ATTACHMENT_UNION: UnionConfig = { { caseName: 'EmbeddedResource', structName: 'MessageEmbeddedResourceAttachment', discriminantValue: 'embeddedResource' }, { caseName: 'Resource', structName: 'MessageResourceAttachment', discriminantValue: 'resource' }, { caseName: 'Annotations', structName: 'MessageAnnotationsAttachment', discriminantValue: 'annotations' }, + { caseName: 'Chat', structName: 'MessageChatAttachment', discriminantValue: 'chat' }, ], unknown: true, }; @@ -1427,7 +1438,7 @@ function generateActionsFile(project: Project): string { // ─── Commands File Generator ───────────────────────────────────────────────── -const COMMAND_ENUMS = ['ReconnectResultType', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode']; +const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode']; const COMMAND_STRUCTS = [ 'InitializeParams', 'InitializeResult', @@ -1435,7 +1446,7 @@ const COMMAND_STRUCTS = [ 'ReconnectParams', 'ReconnectReplayResult', 'ReconnectSnapshotResult', 'SubscribeParams', 'SubscribeView', 'SubscriptionDeliveryOptions', 'SubscribeResult', 'SessionForkSource', 'CreateSessionParams', 'DisposeSessionParams', - 'ChatForkSource', 'CreateChatParams', 'DisposeChatParams', + 'ChatSource', 'CreateChatParams', 'DisposeChatParams', 'ListSessionsParams', 'ListSessionsResult', 'ResourceReadParams', 'ResourceReadResult', 'ResourceWriteParams', 'ResourceWriteResult', diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 94d41c48d..483d7a884 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -738,6 +738,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'MessageEmbeddedResourceAttachment', omitDiscriminants: true }, { name: 'MessageResourceAttachment', omitDiscriminants: true }, { name: 'MessageAnnotationsAttachment', omitDiscriminants: true }, + { name: 'MessageChatAttachment', omitDiscriminants: true }, { name: 'MarkdownResponsePart', omitDiscriminants: true }, { name: 'ContentRef' }, { name: 'ResourceReponsePart', omitDiscriminants: true, rustName: 'ResourceResponsePart' }, @@ -943,6 +944,7 @@ const MESSAGE_ATTACHMENT_UNION: UnionConfig = { { variantName: 'EmbeddedResource', innerType: 'MessageEmbeddedResourceAttachment', wireValue: 'embeddedResource' }, { variantName: 'Resource', innerType: 'MessageResourceAttachment', wireValue: 'resource' }, { variantName: 'Annotations', innerType: 'MessageAnnotationsAttachment', wireValue: 'annotations' }, + { variantName: 'Chat', innerType: 'MessageChatAttachment', wireValue: 'chat' }, ], unknown: true, }; @@ -1058,6 +1060,15 @@ pub enum ChatOrigin { #[serde(rename = "turnId")] turn_id: String, }, + /// Independent side conversation created from a specific turn. + #[serde(rename = "sideChat")] + SideChat { + /// URI of the chat that supplied the side-chat context. + chat: Uri, + /// Turn through which context was supplied. + #[serde(rename = "turnId")] + turn_id: String, + }, /// Spawned by a tool call in another chat. #[serde(rename = "tool")] Tool { @@ -1403,7 +1414,7 @@ pub struct ActionEnvelope { // ─── Commands File Generator ───────────────────────────────────────────────── -const COMMAND_ENUMS = ['ReconnectResultType', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode']; +const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode']; const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: string }[] = [ { name: 'InitializeParams' }, { name: 'InitializeResult' }, @@ -1414,7 +1425,7 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: s { name: 'SubscribeParams' }, { name: 'SubscribeView' }, { name: 'SubscriptionDeliveryOptions' }, { name: 'SubscribeResult' }, { name: 'SessionForkSource' }, { name: 'CreateSessionParams' }, { name: 'DisposeSessionParams' }, - { name: 'ChatForkSource' }, { name: 'CreateChatParams' }, + { name: 'ChatSource' }, { name: 'CreateChatParams' }, { name: 'DisposeChatParams' }, { name: 'ListSessionsParams' }, { name: 'ListSessionsResult' }, { name: 'ResourceReadParams' }, { name: 'ResourceReadResult' }, diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 649be8494..f5b8d119b 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -572,7 +572,7 @@ const STATE_STRUCTS = [ 'ChatInputRequest', 'TextPosition', 'TextRange', 'TextSelection', 'SimpleMessageAttachment', 'MessageEmbeddedResourceAttachment', 'MessageResourceAttachment', - 'MessageAnnotationsAttachment', + 'MessageAnnotationsAttachment', 'MessageChatAttachment', 'MarkdownResponsePart', 'ContentRef', 'ResourceReponsePart', 'ToolCallResponsePart', 'ReasoningResponsePart', 'SystemNotificationResponsePart', 'InputRequestResponsePart', @@ -724,6 +724,7 @@ const MESSAGE_ATTACHMENT_UNION: UnionConfig = { { caseName: 'embeddedResource', structName: 'MessageEmbeddedResourceAttachment', discriminantValue: 'embeddedResource' }, { caseName: 'resource', structName: 'MessageResourceAttachment', discriminantValue: 'resource' }, { caseName: 'annotations', structName: 'MessageAnnotationsAttachment', discriminantValue: 'annotations' }, + { caseName: 'chat', structName: 'MessageChatAttachment', discriminantValue: 'chat' }, ], }; @@ -989,9 +990,22 @@ public struct ChatOriginTool: Codable, Sendable { } } +public struct ChatOriginSideChat: Codable, Sendable { + public var kind: ChatOriginKind + public var chat: String + public var turnId: String + + public init(kind: ChatOriginKind = .sideChat, chat: String, turnId: String) { + self.kind = kind + self.chat = chat + self.turnId = turnId + } +} + public enum ChatOrigin: Codable, Sendable { case user(ChatOriginUser) case fork(ChatOriginFork) + case sideChat(ChatOriginSideChat) case tool(ChatOriginTool) private enum DiscriminatorCodingKeys: String, CodingKey { case kind } @@ -1002,6 +1016,7 @@ public enum ChatOrigin: Codable, Sendable { switch discriminant { case "user": self = .user(try ChatOriginUser(from: decoder)) case "fork": self = .fork(try ChatOriginFork(from: decoder)) + case "sideChat": self = .sideChat(try ChatOriginSideChat(from: decoder)) case "tool": self = .tool(try ChatOriginTool(from: decoder)) default: throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown ChatOrigin kind: \\(discriminant)") @@ -1012,6 +1027,7 @@ public enum ChatOrigin: Codable, Sendable { switch self { case .user(let value): try value.encode(to: encoder) case .fork(let value): try value.encode(to: encoder) + case .sideChat(let value): try value.encode(to: encoder) case .tool(let value): try value.encode(to: encoder) } } @@ -1346,14 +1362,14 @@ function generateActionsFile(project: Project): string { // ─── Commands File Generator ───────────────────────────────────────────────── -const COMMAND_ENUMS = ['ReconnectResultType', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode']; +const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode']; const COMMAND_STRUCTS = [ 'InitializeParams', 'InitializeResult', 'ClientCapabilities', 'Implementation', 'ReconnectParams', 'ReconnectReplayResult', 'ReconnectSnapshotResult', 'SubscribeParams', 'SubscribeView', 'SubscriptionDeliveryOptions', 'SubscribeResult', 'SessionForkSource', 'CreateSessionParams', 'DisposeSessionParams', - 'ChatForkSource', 'CreateChatParams', 'DisposeChatParams', + 'ChatSource', 'CreateChatParams', 'DisposeChatParams', 'ListSessionsParams', 'ListSessionsResult', 'ResourceReadParams', 'ResourceReadResult', 'ResourceWriteParams', 'ResourceWriteResult', diff --git a/types/channels-chat/commands.ts b/types/channels-chat/commands.ts index 0e78d31c6..46000fc65 100644 --- a/types/channels-chat/commands.ts +++ b/types/channels-chat/commands.ts @@ -11,12 +11,29 @@ import type { Message } from './state.js'; // ─── createChat ────────────────────────────────────────────────────────────── /** - * Identifies a source chat and turn to fork from. + * How a new chat uses its source chat and turn. */ -export interface ChatForkSource { - /** URI of the existing chat to fork from */ +export const enum ChatSourceKind { + /** Copy source history through the referenced turn into the new chat. */ + Fork = 'fork', + /** Supply source context without copying it into the new chat's visible history. */ + SideChat = 'sideChat', +} + +/** + * Identifies a source chat and completed turn for a new chat. + * + */ +export interface ChatSource { + /** How the source is used. */ + kind: ChatSourceKind; + /** URI of the existing source chat. */ chat: URI; - /** Turn ID in the source chat; content up to and including this turn's response is copied */ + /** + * Completed turn in the source chat. For a fork, content through this turn is + * copied. For a side chat, that content is supplied as context but is not + * copied into the new chat's visible `turns`. + */ turnId: string; } @@ -36,14 +53,22 @@ export interface CreateChatParams extends BaseParams { chat: URI; /** Optional initial message for the new chat. */ initialMessage?: Message; - /** Optional source chat and turn to fork from. */ - source?: ChatForkSource; + /** + * Optional source chat and completed turn. + * + * The source chat MUST belong to this session. Clients MUST only request + * `kind: "fork"` when the selected agent advertises + * `capabilities.multipleChats.fork`, and + * `kind: "sideChat"` when the selected agent advertises + * `capabilities.multipleChats.sideChat`. + */ + source?: ChatSource; /** * Initial working-directory subset for this chat. Every entry MUST be * present in the owning session's `workingDirectories`; the server MUST * reject any entry that is not. When absent, the chat inherits the full - * session set. Forked chats (`source`) inherit the source chat's - * `workingDirectories`; this field is ignored for forked chats. + * session set. Forked chats (`source.kind === "fork"`) inherit the source + * chat's `workingDirectories`; this field is ignored for forks. * * A client MUST NOT supply this field unless the agent advertises * {@link AgentCapabilities.multipleWorkingDirectories}. @@ -57,8 +82,8 @@ export interface CreateChatParams extends BaseParams { * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY * reject creation that omits it, or fall back to the first of the chat's * directories. Fixed at creation and reported (read-only) on - * {@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork - * inherits the source chat's primary). + * {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a + * `source.kind === "fork"` chat inherits the source chat's primary). */ primaryWorkingDirectory?: URI; } diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index bfccceed5..a409377a0 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -175,6 +175,8 @@ export const enum ChatOriginKind { User = 'user', /** Forked from an existing chat at a specific turn. */ Fork = 'fork', + /** Created as an independent side conversation from a specific turn. */ + SideChat = 'sideChat', /** Spawned by a tool call running in another chat (e.g. a sub-agent delegation). */ Tool = 'tool', } @@ -194,6 +196,7 @@ export const enum ChatOriginKind { export type ChatOrigin = | { kind: ChatOriginKind.User } | { kind: ChatOriginKind.Fork; chat: URI; turnId: string } + | { kind: ChatOriginKind.SideChat; chat: URI; turnId: string } | { kind: ChatOriginKind.Tool; chat: URI; toolCallId: string }; /** @@ -513,6 +516,8 @@ export const enum MessageAttachmentKind { Resource = 'resource', /** An attachment that references annotations on an annotations channel. */ Annotations = 'annotations', + /** An attachment that references a bounded transcript from another chat. */ + Chat = 'chat', } /** @@ -775,6 +780,30 @@ export interface MessageAnnotationsAttachment extends MessageAttachmentBase { annotationIds?: string[]; } +/** + * An attachment that references a chat transcript through a fixed completed + * turn. + * + * The referenced chat MUST belong to the same session as the message's chat. + * The host resolves the transcript from its first retained turn through + * `endTurn`, inclusive, when accepting the message. Later turns do not + * change the context represented by an already-sent attachment. + * + * Hosts MUST NOT recursively expand chat attachments found inside the + * referenced transcript. Clients SHOULD keep rendering `label` if the + * referenced chat is later pruned, and treat opening `resource` as best-effort. + * + * @category Turn Types + */ +export interface MessageChatAttachment extends MessageAttachmentBase { + /** Discriminant */ + type: MessageAttachmentKind.Chat; + /** URI of the referenced chat. */ + resource: URI; + /** Last completed turn included in the referenced transcript. */ + endTurn: string; +} + /** * An attachment associated with a {@link Message}. * @@ -784,7 +813,8 @@ export type MessageAttachment = | SimpleMessageAttachment | MessageEmbeddedResourceAttachment | MessageResourceAttachment - | MessageAnnotationsAttachment; + | MessageAnnotationsAttachment + | MessageChatAttachment; // ─── Response Parts ────────────────────────────────────────────────────────── diff --git a/types/channels-root/state.ts b/types/channels-root/state.ts index 92b4fa08e..fd6c20b12 100644 --- a/types/channels-root/state.ts +++ b/types/channels-root/state.ts @@ -107,7 +107,8 @@ export interface AgentCapabilities { * The agent can host more than one concurrent chat per session. When absent, * clients MUST NOT call `createChat` to open chats beyond the default one the * session starts with. An empty object `{}` advertises multi-chat without - * forking; set {@link MultipleChatsCapability.fork} to also allow forking. + * source-based creation; set {@link MultipleChatsCapability.fork} or + * {@link MultipleChatsCapability.sideChat} to allow the corresponding mode. */ multipleChats?: MultipleChatsCapability; /** @@ -131,10 +132,21 @@ export interface AgentCapabilities { export interface MultipleChatsCapability { /** * The agent can fork a chat from a specific turn. When absent or `false`, - * clients MUST NOT pass a {@link ChatForkSource} (`source`) to `createChat`. + * clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to + * `createChat`. * Forking always implies multi-chat support. */ fork?: boolean; + /** + * The agent can create a side chat from a specific turn. When absent or + * `false`, clients MUST NOT pass a {@link ChatSource} with + * `kind: "sideChat"` to `createChat`. + * + * A side chat receives the source turn as context without copying the source + * transcript into its own visible history. Side-chat support always implies + * multi-chat support. + */ + sideChat?: boolean; } /** diff --git a/types/index.ts b/types/index.ts index fd5d54216..a6ab6f6e9 100644 --- a/types/index.ts +++ b/types/index.ts @@ -14,6 +14,8 @@ export type { RootState, RootConfigState, AgentInfo, + AgentCapabilities, + MultipleChatsCapability, ProtectedResourceMetadata, ProjectInfo, SessionModelInfo, @@ -39,6 +41,7 @@ export type { MessageEmbeddedResourceAttachment, MessageResourceAttachment, MessageAnnotationsAttachment, + MessageChatAttachment, MarkdownResponsePart, ContentRef, ToolCallResponsePart, @@ -285,7 +288,7 @@ export type { SessionForkSource, DisposeSessionParams, CreateChatParams, - ChatForkSource, + ChatSource, DisposeChatParams, CreateTerminalParams, DisposeTerminalParams, @@ -338,7 +341,7 @@ export type { ChangesetOperationFollowUp, } from './commands.js'; -export { ReconnectResultType, ContentEncoding, CompletionItemKind, ResourceType, ResourceWriteMode } from './commands.js'; +export { ReconnectResultType, ChatSourceKind, ContentEncoding, CompletionItemKind, ResourceType, ResourceWriteMode } from './commands.js'; // Notification types export type { diff --git a/types/test-cases/round-trips/030-agent-side-chat-capability.json b/types/test-cases/round-trips/030-agent-side-chat-capability.json new file mode 100644 index 000000000..1eef9b1b9 --- /dev/null +++ b/types/test-cases/round-trips/030-agent-side-chat-capability.json @@ -0,0 +1,58 @@ +{ + "name": "agent-side-chat-capability", + "group": "A", + "description": "AgentInfo advertises capability-gated side-chat creation through capabilities.multipleChats.sideChat.", + "type": "InitializeResult", + "input": { + "protocolVersion": "0.6.0", + "serverSeq": 3, + "snapshots": [ + { + "resource": "ahp-root://", + "state": { + "agents": [ + { + "provider": "example", + "displayName": "Example", + "description": "Example agent", + "models": [], + "capabilities": { + "multipleChats": { + "sideChat": true + } + } + } + ] + }, + "fromSeq": 3 + } + ] + }, + "acceptableOutputs": [ + { + "protocolVersion": "0.6.0", + "serverSeq": 3, + "snapshots": [ + { + "resource": "ahp-root://", + "state": { + "agents": [ + { + "provider": "example", + "displayName": "Example", + "description": "Example agent", + "models": [], + "capabilities": { + "multipleChats": { + "sideChat": true + } + } + } + ] + }, + "fromSeq": 3 + } + ] + } + ] +} diff --git a/types/test-cases/round-trips/031-side-chat-origin.json b/types/test-cases/round-trips/031-side-chat-origin.json new file mode 100644 index 000000000..edf1c4929 --- /dev/null +++ b/types/test-cases/round-trips/031-side-chat-origin.json @@ -0,0 +1,36 @@ +{ + "name": "side-chat-origin", + "group": "A", + "description": "A session/chatAdded action preserves side-chat provenance through the ChatOrigin discriminated union.", + "type": "StateAction", + "input": { + "type": "session/chatAdded", + "summary": { + "resource": "ahp-chat:/side", + "title": "Side investigation", + "status": 1, + "modifiedAt": "2026-07-13T19:00:00.000Z", + "origin": { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turnId": "turn-12" + } + } + }, + "acceptableOutputs": [ + { + "type": "session/chatAdded", + "summary": { + "resource": "ahp-chat:/side", + "title": "Side investigation", + "status": 1, + "modifiedAt": "2026-07-13T19:00:00.000Z", + "origin": { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turnId": "turn-12" + } + } + } + ] +} diff --git a/types/test-cases/round-trips/032-chat-transcript-attachment.json b/types/test-cases/round-trips/032-chat-transcript-attachment.json new file mode 100644 index 000000000..62e1af9d9 --- /dev/null +++ b/types/test-cases/round-trips/032-chat-transcript-attachment.json @@ -0,0 +1,46 @@ +{ + "name": "chat-transcript-attachment", + "group": "A", + "description": "A chat/turnStarted action preserves a bounded chat transcript attachment through the MessageAttachment discriminated union.", + "type": "StateAction", + "input": { + "type": "chat/turnStarted", + "turnId": "turn-13", + "startedAt": "2026-07-13T19:01:00.000Z", + "message": { + "text": "Use this investigation.", + "origin": { + "kind": "user" + }, + "attachments": [ + { + "type": "chat", + "label": "Side investigation", + "resource": "ahp-chat:/side", + "endTurn": "side-turn-3" + } + ] + } + }, + "acceptableOutputs": [ + { + "type": "chat/turnStarted", + "turnId": "turn-13", + "startedAt": "2026-07-13T19:01:00.000Z", + "message": { + "text": "Use this investigation.", + "origin": { + "kind": "user" + }, + "attachments": [ + { + "type": "chat", + "label": "Side investigation", + "resource": "ahp-chat:/side", + "endTurn": "side-turn-3" + } + ] + } + } + ] +} diff --git a/types/test-cases/round-trips/033-chat-source-side-chat.json b/types/test-cases/round-trips/033-chat-source-side-chat.json new file mode 100644 index 000000000..2a0ead39b --- /dev/null +++ b/types/test-cases/round-trips/033-chat-source-side-chat.json @@ -0,0 +1,18 @@ +{ + "name": "chat-source-side-chat", + "group": "A", + "description": "ChatSource requires an explicit sideChat discriminator alongside its source chat and completed turn.", + "type": "ChatSource", + "input": { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turnId": "turn-12" + }, + "acceptableOutputs": [ + { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turnId": "turn-12" + } + ] +} From 743be152d979f7b98ff60aa6d6db23f7b61111ae Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 22 Jul 2026 14:33:17 -0700 Subject: [PATCH 2/9] chat: support active-turn side chats Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahptypes/commands.generated.go | 83 ++++++- clients/go/ahptypes/state.generated.go | 96 +++++++- .../generated/Commands.generated.kt | 79 +++++- .../generated/State.generated.kt | 90 ++++++- clients/rust/crates/ahp-types/src/commands.rs | 90 ++++++- clients/rust/crates/ahp-types/src/state.rs | 98 +++++++- .../Generated/Commands.generated.swift | 77 +++++- .../Generated/State.generated.swift | 86 ++++++- docs/guide/state-model.md | 5 +- docs/proposals/multi-chat.md | 7 +- docs/specification/chat-channel.md | 19 +- schema/actions.schema.json | 141 ++++++++--- schema/commands.schema.json | 225 ++++++++++++++---- schema/errors.schema.json | 225 ++++++++++++++---- schema/notifications.schema.json | 131 +++++++--- schema/state.schema.json | 131 +++++++--- scripts/generate-go.ts | 38 ++- scripts/generate-json-schema.test.ts | 21 ++ scripts/generate-json-schema.ts | 10 +- scripts/generate-kotlin.ts | 37 ++- scripts/generate-rust.ts | 128 +++++++++- scripts/generate-swift.ts | 44 +++- types/channels-chat/commands.ts | 56 ++++- types/channels-chat/state.ts | 56 ++++- types/channels-root/state.ts | 6 +- types/index.ts | 6 + .../reducers/172-session-chatremoved.json | 10 +- .../round-trips/031-side-chat-origin.json | 12 +- .../033-chat-source-side-chat.json | 12 +- .../034-side-chat-origin-active.json | 42 ++++ .../035-chat-source-side-chat-active.json | 24 ++ 31 files changed, 1756 insertions(+), 329 deletions(-) create mode 100644 types/test-cases/round-trips/034-side-chat-origin-active.json create mode 100644 types/test-cases/round-trips/035-chat-source-side-chat-active.json diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index a090ccf5d..7f6485e6c 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -384,16 +384,32 @@ type DisposeSessionParams struct { Channel URI `json:"channel"` } -// Identifies a source chat and completed turn for a new chat. -type ChatSource struct { - // How the source is used. +// Copies source history through a completed turn into the new chat. +type ForkChatSource struct { + // Discriminant Kind ChatSourceKind `json:"kind"` // URI of the existing source chat. Chat URI `json:"chat"` - // Completed turn in the source chat. For a fork, content through this turn is - // copied. For a side chat, that content is supplied as context but is not - // copied into the new chat's visible `turns`. - TurnId string `json:"turnId"` + // Completed turn in the source chat. Content through this turn is copied into + // the new chat's visible `turns`. + Turn CompletedChatSourceTurn `json:"turn"` +} + +// Supplies source context to a new side chat without copying it into the side +// chat's visible history. +type SideChatSource struct { + // Discriminant + Kind ChatSourceKind `json:"kind"` + // URI of the existing source chat. + Chat URI `json:"chat"` + // Source turn in the source chat. + // + // When this is `kind: "completed"`, the side chat is seeded from the source + // chat's retained history through that turn. When this is `kind: "active"`, + // the host snapshots the source chat's retained history plus the active turn's + // current user message and any partial assistant response already available + // when accepting `createChat`. + Turn ChatSourceTurn `json:"turn"` } // Creates a new chat within a session. @@ -404,13 +420,15 @@ type CreateChatParams struct { Chat URI `json:"chat"` // Optional initial message for the new chat. InitialMessage *Message `json:"initialMessage,omitempty"` - // Optional source chat and completed turn. + // Optional source chat and source turn. // // The source chat MUST belong to this session. Clients MUST only request // `kind: "fork"` when the selected agent advertises // `capabilities.multipleChats.fork`, and // `kind: "sideChat"` when the selected agent advertises - // `capabilities.multipleChats.sideChat`. + // `capabilities.multipleChats.sideChat`. Forks require + // `source.turn.kind: "completed"`. Side chats accept either a completed turn + // or the source chat's current active turn. Source *ChatSource `json:"source,omitempty"` // Initial working-directory subset for this chat. Every entry MUST be // present in the owning session's `workingDirectories`; the server MUST @@ -1097,6 +1115,53 @@ type ChangesetOperationFollowUp struct { External *bool `json:"external,omitempty"` } +// ─── ChatSource Union ───────────────────────────────────────────────── + +// ChatSource identifies how a new chat uses a source chat. +type ChatSource struct { + Value isChatSource +} + +// isChatSource is the marker interface implemented by every +// concrete variant of ChatSource. +type isChatSource interface{ isChatSource() } + +func (*ForkChatSource) isChatSource() {} +func (*SideChatSource) isChatSource() {} + +// UnmarshalJSON decodes the variant indicated by the "kind" discriminator. +func (u *ChatSource) UnmarshalJSON(data []byte) error { + disc, _, err := readDiscriminator(data, "kind") + if err != nil { + return err + } + switch disc { + case "fork": + var value ForkChatSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "sideChat": + var value SideChatSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + default: + return &json.UnmarshalTypeError{Value: "ChatSource", Type: nil} + } + return nil +} + +// MarshalJSON encodes the active variant back to JSON. +func (u ChatSource) MarshalJSON() ([]byte, error) { + if u.Value == nil { + return []byte("null"), nil + } + return json.Marshal(u.Value) +} + // ─── ReconnectResult Union ──────────────────────────────────────────── // ReconnectResult is the result of the `reconnect` command. diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 7c8c89a71..03f5a4445 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -75,6 +75,17 @@ const ( ChatOriginKindTool ChatOriginKind = "tool" ) +// Discriminant for {@link ChatSourceTurn} — which kind of source-turn snapshot +// a fork or side chat was created from. +type ChatSourceTurnKind string + +const ( + // A completed turn retained in the source chat's `turns`. + ChatSourceTurnKindCompleted ChatSourceTurnKind = "completed" + // The source chat's current in-progress `activeTurn`. + ChatSourceTurnKindActive ChatSourceTurnKind = "active" +) + // How a user can interact with a chat. // // - `Full` — user can send messages and watch (default when absent) @@ -612,8 +623,10 @@ type MultipleChatsCapability struct { // `kind: "sideChat"` to `createChat`. // // A side chat receives the source turn as context without copying the source - // transcript into its own visible history. Side-chat support always implies - // multi-chat support. + // transcript into its own visible history. The source may be a completed turn + // or the source chat's current active turn; when active, the host snapshots + // the available partial assistant response at creation time. Side-chat + // support always implies multi-chat support. SideChat *bool `json:"sideChat,omitempty"` } @@ -1180,6 +1193,28 @@ type ChatSummary struct { PrimaryWorkingDirectory *URI `json:"primaryWorkingDirectory,omitempty"` } +// A completed turn used as a chat source or recorded in chat origin. +type CompletedChatSourceTurn struct { + // Discriminant + Kind ChatSourceTurnKind `json:"kind"` + // Turn identifier in the source chat. + TurnId string `json:"turnId"` +} + +// The current in-progress turn used as a side-chat source or recorded in chat +// origin. +// +// When a host accepts side-chat creation from an active turn, it snapshots the +// source chat's retained history plus that turn's current user message and any +// partial assistant response already available. Later source-turn deltas do not +// retroactively change the created side chat's starting context. +type ActiveChatSourceTurn struct { + // Discriminant + Kind ChatSourceTurnKind `json:"kind"` + // Active turn identifier in the source chat. + TurnId string `json:"turnId"` +} + // A message queued for future delivery to the agent. // // Steering messages are injected into the current turn mid-flight. @@ -4785,6 +4820,51 @@ func (u SessionInputRequest) MarshalJSON() ([]byte, error) { return json.Marshal(u.Value) } +// ChatSourceTurn identifies whether a source-turn snapshot came from a completed or active turn. +type ChatSourceTurn struct { + Value isChatSourceTurn +} + +// isChatSourceTurn is the marker interface implemented by every +// concrete variant of ChatSourceTurn. +type isChatSourceTurn interface{ isChatSourceTurn() } + +func (*CompletedChatSourceTurn) isChatSourceTurn() {} +func (*ActiveChatSourceTurn) isChatSourceTurn() {} + +// UnmarshalJSON decodes the variant indicated by the "kind" discriminator. +func (u *ChatSourceTurn) UnmarshalJSON(data []byte) error { + disc, _, err := readDiscriminator(data, "kind") + if err != nil { + return err + } + switch disc { + case "completed": + var value CompletedChatSourceTurn + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "active": + var value ActiveChatSourceTurn + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + default: + return &json.UnmarshalTypeError{Value: "ChatSourceTurn", Type: nil} + } + return nil +} + +// MarshalJSON encodes the active variant back to JSON. +func (u ChatSourceTurn) MarshalJSON() ([]byte, error) { + if u.Value == nil { + return []byte("null"), nil + } + return json.Marshal(u.Value) +} + // ChatOrigin describes how a chat came into existence. type ChatOrigin struct { Value isChatOrigin @@ -4800,17 +4880,17 @@ type ChatUserOrigin struct { func (*ChatUserOrigin) isChatOrigin() {} type ChatForkOrigin struct { - Kind ChatOriginKind `json:"kind"` - Chat URI `json:"chat"` - TurnId string `json:"turnId"` + Kind ChatOriginKind `json:"kind"` + Chat URI `json:"chat"` + Turn CompletedChatSourceTurn `json:"turn"` } func (*ChatForkOrigin) isChatOrigin() {} type ChatSideChatOrigin struct { - Kind ChatOriginKind `json:"kind"` - Chat URI `json:"chat"` - TurnId string `json:"turnId"` + Kind ChatOriginKind `json:"kind"` + Chat URI `json:"chat"` + Turn ChatSourceTurn `json:"turn"` } func (*ChatSideChatOrigin) isChatOrigin() {} 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 6fa3e4268..a3f270238 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 @@ -467,9 +467,9 @@ data class DisposeSessionParams( ) @Serializable -data class ChatSource( +data class ForkChatSource( /** - * How the source is used. + * Discriminant */ val kind: ChatSourceKind, /** @@ -477,11 +477,32 @@ data class ChatSource( */ val chat: String, /** - * Completed turn in the source chat. For a fork, content through this turn is - * copied. For a side chat, that content is supplied as context but is not - * copied into the new chat's visible `turns`. + * Completed turn in the source chat. Content through this turn is copied into + * the new chat's visible `turns`. */ - val turnId: String + val turn: CompletedChatSourceTurn +) + +@Serializable +data class SideChatSource( + /** + * Discriminant + */ + val kind: ChatSourceKind, + /** + * URI of the existing source chat. + */ + val chat: String, + /** + * Source turn in the source chat. + * + * When this is `kind: "completed"`, the side chat is seeded from the source + * chat's retained history through that turn. When this is `kind: "active"`, + * the host snapshots the source chat's retained history plus the active turn's + * current user message and any partial assistant response already available + * when accepting `createChat`. + */ + val turn: ChatSourceTurn ) @Serializable @@ -499,13 +520,15 @@ data class CreateChatParams( */ val initialMessage: Message? = null, /** - * Optional source chat and completed turn. + * Optional source chat and source turn. * * The source chat MUST belong to this session. Clients MUST only request * `kind: "fork"` when the selected agent advertises * `capabilities.multipleChats.fork`, and * `kind: "sideChat"` when the selected agent advertises - * `capabilities.multipleChats.sideChat`. + * `capabilities.multipleChats.sideChat`. Forks require + * `source.turn.kind: "completed"`. Side chats accept either a completed turn + * or the source chat's current active turn. */ val source: ChatSource? = null, /** @@ -1269,6 +1292,46 @@ data class ChangesetOperationFollowUp( val external: Boolean? = null ) +// ─── ChatSource Union ─────────────────────────────────────────────────────── + +@Serializable(with = ChatSourceSerializer::class) +sealed interface ChatSource + +@JvmInline +value class ChatSourceFork(val value: ForkChatSource) : ChatSource +@JvmInline +value class ChatSourceSideChat(val value: SideChatSource) : ChatSource + +internal object ChatSourceSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("ChatSource") + + override fun deserialize(decoder: Decoder): ChatSource { + val input = decoder as? JsonDecoder + ?: error("ChatSource can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for ChatSource") + val discriminant = (obj["kind"] as? JsonPrimitive)?.content + ?: error("Missing kind discriminator on ChatSource") + return when (discriminant) { + "fork" -> ChatSourceFork(input.json.decodeFromJsonElement(ForkChatSource.serializer(), element)) + "sideChat" -> ChatSourceSideChat(input.json.decodeFromJsonElement(SideChatSource.serializer(), element)) + else -> error("Unknown ChatSource discriminator: $discriminant") + } + } + + override fun serialize(encoder: Encoder, value: ChatSource) { + val output = encoder as? JsonEncoder + ?: error("ChatSource can only be serialized to JSON") + val element: JsonElement = when (value) { + is ChatSourceFork -> output.json.encodeToJsonElement(ForkChatSource.serializer(), value.value) + is ChatSourceSideChat -> output.json.encodeToJsonElement(SideChatSource.serializer(), value.value) + } + output.encodeJsonElement(element) + } +} + // ─── ReconnectResult Union ────────────────────────────────────────────────── @Serializable(with = ReconnectResultSerializer::class) 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 426a1633e..906063b16 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 @@ -195,6 +195,24 @@ enum class ChatOriginKind { TOOL } +/** + * Discriminant for {@link ChatSourceTurn} — which kind of source-turn snapshot + * a fork or side chat was created from. + */ +@Serializable +enum class ChatSourceTurnKind { + /** + * A completed turn retained in the source chat's `turns`. + */ + @SerialName("completed") + COMPLETED, + /** + * The source chat's current in-progress `activeTurn`. + */ + @SerialName("active") + ACTIVE +} + /** * How a user can interact with a chat. * @@ -992,8 +1010,10 @@ data class MultipleChatsCapability( * `kind: "sideChat"` to `createChat`. * * A side chat receives the source turn as context without copying the source - * transcript into its own visible history. Side-chat support always implies - * multi-chat support. + * transcript into its own visible history. The source may be a completed turn + * or the source chat's current active turn; when active, the host snapshots + * the available partial assistant response at creation time. Side-chat + * support always implies multi-chat support. */ val sideChat: Boolean? = null ) @@ -1324,6 +1344,30 @@ data class ChatSummary( val primaryWorkingDirectory: String? = null ) +@Serializable +data class CompletedChatSourceTurn( + /** + * Discriminant + */ + val kind: ChatSourceTurnKind, + /** + * Turn identifier in the source chat. + */ + val turnId: String +) + +@Serializable +data class ActiveChatSourceTurn( + /** + * Discriminant + */ + val kind: ChatSourceTurnKind, + /** + * Active turn identifier in the source chat. + */ + val turnId: String +) + @Serializable data class SessionState( /** @@ -4632,6 +4676,44 @@ data class ResourceChange( // ─── Discriminated Unions ─────────────────────────────────────────────────── +@Serializable(with = ChatSourceTurnSerializer::class) +sealed interface ChatSourceTurn + +@JvmInline +value class ChatSourceTurnCompleted(val value: CompletedChatSourceTurn) : ChatSourceTurn +@JvmInline +value class ChatSourceTurnActive(val value: ActiveChatSourceTurn) : ChatSourceTurn + +internal object ChatSourceTurnSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("ChatSourceTurn") + + override fun deserialize(decoder: Decoder): ChatSourceTurn { + val input = decoder as? JsonDecoder + ?: error("ChatSourceTurn can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for ChatSourceTurn") + val discriminant = (obj["kind"] as? JsonPrimitive)?.content + ?: error("Missing kind discriminator on ChatSourceTurn") + return when (discriminant) { + "completed" -> ChatSourceTurnCompleted(input.json.decodeFromJsonElement(CompletedChatSourceTurn.serializer(), element)) + "active" -> ChatSourceTurnActive(input.json.decodeFromJsonElement(ActiveChatSourceTurn.serializer(), element)) + else -> error("Unknown ChatSourceTurn discriminator: $discriminant") + } + } + + override fun serialize(encoder: Encoder, value: ChatSourceTurn) { + val output = encoder as? JsonEncoder + ?: error("ChatSourceTurn can only be serialized to JSON") + val element: JsonElement = when (value) { + is ChatSourceTurnCompleted -> output.json.encodeToJsonElement(CompletedChatSourceTurn.serializer(), value.value) + is ChatSourceTurnActive -> output.json.encodeToJsonElement(ActiveChatSourceTurn.serializer(), value.value) + } + output.encodeJsonElement(element) + } +} + @Serializable(with = ChatOriginSerializer::class) sealed interface ChatOrigin { @JvmInline value class User(val value: ChatOriginUser) : ChatOrigin @@ -4650,7 +4732,7 @@ data class ChatOriginUser( data class ChatOriginFork( val kind: ChatOriginKind = ChatOriginKind.FORK, val chat: String, - val turnId: String, + val turn: CompletedChatSourceTurn, ) @Serializable @@ -4664,7 +4746,7 @@ data class ChatOriginTool( data class ChatOriginSideChat( val kind: ChatOriginKind = ChatOriginKind.SIDE_CHAT, val chat: String, - val turnId: String, + val turn: ChatSourceTurn, ) internal object ChatOriginSerializer : KSerializer { diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index 459c16f79..1f83ae382 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -15,9 +15,9 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; use crate::actions::{ActionEnvelope, StateAction}; #[allow(unused_imports)] use crate::state::{ - AgentSelection, ContentRef, Message, MessageAttachment, ModelSelection, SessionActiveClient, - SessionConfigSchema, SessionSummary, Snapshot, SnapshotState, TelemetryCapabilities, - TerminalClaim, TextRange, Turn, + AgentSelection, ChatSourceTurn, CompletedChatSourceTurn, ContentRef, Message, + MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, + Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn, }; // ─── Enums ──────────────────────────────────────────────────────────── @@ -479,18 +479,36 @@ pub struct DisposeSessionParams { pub channel: Uri, } -/// Identifies a source chat and completed turn for a new chat. +/// Copies source history through a completed turn into the new chat. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ChatSource { - /// How the source is used. +pub struct ForkChatSource { + /// Discriminant pub kind: ChatSourceKind, /// URI of the existing source chat. pub chat: Uri, - /// Completed turn in the source chat. For a fork, content through this turn is - /// copied. For a side chat, that content is supplied as context but is not - /// copied into the new chat's visible `turns`. - pub turn_id: String, + /// Completed turn in the source chat. Content through this turn is copied into + /// the new chat's visible `turns`. + pub turn: CompletedChatSourceTurn, +} + +/// Supplies source context to a new side chat without copying it into the side +/// chat's visible history. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SideChatSource { + /// Discriminant + pub kind: ChatSourceKind, + /// URI of the existing source chat. + pub chat: Uri, + /// Source turn in the source chat. + /// + /// When this is `kind: "completed"`, the side chat is seeded from the source + /// chat's retained history through that turn. When this is `kind: "active"`, + /// the host snapshots the source chat's retained history plus the active turn's + /// current user message and any partial assistant response already available + /// when accepting `createChat`. + pub turn: ChatSourceTurn, } /// Creates a new chat within a session. @@ -504,13 +522,15 @@ pub struct CreateChatParams { /// Optional initial message for the new chat. #[serde(default, skip_serializing_if = "Option::is_none")] pub initial_message: Option, - /// Optional source chat and completed turn. + /// Optional source chat and source turn. /// /// The source chat MUST belong to this session. Clients MUST only request /// `kind: "fork"` when the selected agent advertises /// `capabilities.multipleChats.fork`, and /// `kind: "sideChat"` when the selected agent advertises - /// `capabilities.multipleChats.sideChat`. + /// `capabilities.multipleChats.sideChat`. Forks require + /// `source.turn.kind: "completed"`. Side chats accept either a completed turn + /// or the source chat's current active turn. #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, /// Initial working-directory subset for this chat. Every entry MUST be @@ -1322,6 +1342,52 @@ pub struct ChangesetOperationFollowUp { pub external: Option, } +// ─── ChatSource Union ───────────────────────────────────────────────── + +/// How a new chat uses a source chat. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "serde_json::Value", into = "serde_json::Value")] +pub enum ChatSource { + Fork(ForkChatSource), + SideChat(SideChatSource), +} + +impl TryFrom for ChatSource { + type Error = String; + + fn try_from(value: serde_json::Value) -> Result { + let Some(object) = value.as_object() else { + return Err("ChatSource must be a JSON object".to_string()); + }; + let Some(kind) = object.get("kind").and_then(|value| value.as_str()) else { + return Err("ChatSource is missing a string kind discriminant".to_string()); + }; + + match kind { + "fork" => serde_json::from_value(value) + .map(|inner| Self::Fork(inner)) + .map_err(|error| error.to_string()), + "sideChat" => serde_json::from_value(value) + .map(|inner| Self::SideChat(inner)) + .map_err(|error| error.to_string()), + _ => Err(format!("unknown kind: {kind}")), + } + } +} + +impl From for serde_json::Value { + fn from(value: ChatSource) -> Self { + match value { + ChatSource::Fork(inner) => { + serde_json::to_value(inner).expect("serializing ChatSource::Fork") + } + ChatSource::SideChat(inner) => { + serde_json::to_value(inner).expect("serializing ChatSource::SideChat") + } + } + } +} + // ─── ReconnectResult Union ──────────────────────────────────────────── /// Result of the `reconnect` command. diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index d470c4ee0..3f25479bc 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -154,6 +154,18 @@ pub enum ChatOriginKind { Tool, } +/// Discriminant for {@link ChatSourceTurn} — which kind of source-turn snapshot +/// a fork or side chat was created from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ChatSourceTurnKind { + /// A completed turn retained in the source chat's `turns`. + #[serde(rename = "completed")] + Completed, + /// The source chat's current in-progress `activeTurn`. + #[serde(rename = "active")] + Active, +} + /// How a user can interact with a chat. /// /// - `Full` — user can send messages and watch (default when absent) @@ -837,8 +849,10 @@ pub struct MultipleChatsCapability { /// `kind: "sideChat"` to `createChat`. /// /// A side chat receives the source turn as context without copying the source - /// transcript into its own visible history. Side-chat support always implies - /// multi-chat support. + /// transcript into its own visible history. The source may be a completed turn + /// or the source chat's current active turn; when active, the host snapshots + /// the available partial assistant response at creation time. Side-chat + /// support always implies multi-chat support. #[serde(default, skip_serializing_if = "Option::is_none")] pub side_chat: Option, } @@ -1144,6 +1158,32 @@ pub struct ChatSummary { pub primary_working_directory: Option, } +/// A completed turn used as a chat source or recorded in chat origin. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompletedChatSourceTurn { + /// Discriminant + pub kind: ChatSourceTurnKind, + /// Turn identifier in the source chat. + pub turn_id: String, +} + +/// The current in-progress turn used as a side-chat source or recorded in chat +/// origin. +/// +/// When a host accepts side-chat creation from an active turn, it snapshots the +/// source chat's retained history plus that turn's current user message and any +/// partial assistant response already available. Later source-turn deltas do not +/// retroactively change the created side chat's starting context. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActiveChatSourceTurn { + /// Discriminant + pub kind: ChatSourceTurnKind, + /// Active turn identifier in the source chat. + pub turn_id: String, +} + /// Full state for a single session, loaded when a client subscribes to the session's URI. /// /// Inlines (denormalizes) every {@link SessionMetadata} field directly onto @@ -4216,6 +4256,50 @@ pub struct ResourceChange { // ─── Discriminated Unions ───────────────────────────────────────────── +/// A source-turn snapshot used when creating or describing a chat. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "serde_json::Value", into = "serde_json::Value")] +pub enum ChatSourceTurn { + Completed(CompletedChatSourceTurn), + Active(ActiveChatSourceTurn), +} + +impl TryFrom for ChatSourceTurn { + type Error = String; + + fn try_from(value: serde_json::Value) -> Result { + let Some(object) = value.as_object() else { + return Err("ChatSourceTurn must be a JSON object".to_string()); + }; + let Some(kind) = object.get("kind").and_then(|value| value.as_str()) else { + return Err("ChatSourceTurn is missing a string kind discriminant".to_string()); + }; + + match kind { + "completed" => serde_json::from_value(value) + .map(|inner| Self::Completed(inner)) + .map_err(|error| error.to_string()), + "active" => serde_json::from_value(value) + .map(|inner| Self::Active(inner)) + .map_err(|error| error.to_string()), + _ => Err(format!("unknown kind: {kind}")), + } + } +} + +impl From for serde_json::Value { + fn from(value: ChatSourceTurn) -> Self { + match value { + ChatSourceTurn::Completed(inner) => { + serde_json::to_value(inner).expect("serializing ChatSourceTurn::Completed") + } + ChatSourceTurn::Active(inner) => { + serde_json::to_value(inner).expect("serializing ChatSourceTurn::Active") + } + } + } +} + /// How a chat came into existence. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind")] @@ -4228,18 +4312,16 @@ pub enum ChatOrigin { Fork { /// URI of the chat this one was forked from. chat: Uri, - /// Turn the fork was taken from. - #[serde(rename = "turnId")] - turn_id: String, + /// Completed source-turn snapshot the fork was taken from. + turn: CompletedChatSourceTurn, }, /// Independent side conversation created from a specific turn. #[serde(rename = "sideChat")] SideChat { /// URI of the chat that supplied the side-chat context. chat: Uri, - /// Turn through which context was supplied. - #[serde(rename = "turnId")] - turn_id: String, + /// Source-turn snapshot through which context was supplied. + turn: ChatSourceTurn, }, /// Spawned by a tool call in another chat. #[serde(rename = "tool")] diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index e926ebfe6..5b9cfce30 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -454,24 +454,48 @@ public struct DisposeSessionParams: Codable, Sendable { } } -public struct ChatSource: Codable, Sendable { - /// How the source is used. +public struct ForkChatSource: Codable, Sendable { + /// Discriminant public var kind: ChatSourceKind /// URI of the existing source chat. public var chat: String - /// Completed turn in the source chat. For a fork, content through this turn is - /// copied. For a side chat, that content is supplied as context but is not - /// copied into the new chat's visible `turns`. - public var turnId: String + /// Completed turn in the source chat. Content through this turn is copied into + /// the new chat's visible `turns`. + public var turn: CompletedChatSourceTurn public init( kind: ChatSourceKind, chat: String, - turnId: String + turn: CompletedChatSourceTurn ) { self.kind = kind self.chat = chat - self.turnId = turnId + self.turn = turn + } +} + +public struct SideChatSource: Codable, Sendable { + /// Discriminant + public var kind: ChatSourceKind + /// URI of the existing source chat. + public var chat: String + /// Source turn in the source chat. + /// + /// When this is `kind: "completed"`, the side chat is seeded from the source + /// chat's retained history through that turn. When this is `kind: "active"`, + /// the host snapshots the source chat's retained history plus the active turn's + /// current user message and any partial assistant response already available + /// when accepting `createChat`. + public var turn: ChatSourceTurn + + public init( + kind: ChatSourceKind, + chat: String, + turn: ChatSourceTurn + ) { + self.kind = kind + self.chat = chat + self.turn = turn } } @@ -482,13 +506,15 @@ public struct CreateChatParams: Codable, Sendable { public var chat: String /// Optional initial message for the new chat. public var initialMessage: Message? - /// Optional source chat and completed turn. + /// Optional source chat and source turn. /// /// The source chat MUST belong to this session. Clients MUST only request /// `kind: "fork"` when the selected agent advertises /// `capabilities.multipleChats.fork`, and /// `kind: "sideChat"` when the selected agent advertises - /// `capabilities.multipleChats.sideChat`. + /// `capabilities.multipleChats.sideChat`. Forks require + /// `source.turn.kind: "completed"`. Side chats accept either a completed turn + /// or the source chat's current active turn. public var source: ChatSource? /// Initial working-directory subset for this chat. Every entry MUST be /// present in the owning session's `workingDirectories`; the server MUST @@ -1433,6 +1459,37 @@ public struct ChangesetOperationFollowUp: Codable, Sendable { } } +// MARK: - Command Unions + +public enum ChatSource: Codable, Sendable { + case fork(ForkChatSource) + case sideChat(SideChatSource) + + private enum DiscriminantKey: String, CodingKey { + case discriminant = "kind" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + let discriminant = try container.decode(String.self, forKey: .discriminant) + switch discriminant { + case "fork": + self = .fork(try ForkChatSource(from: decoder)) + case "sideChat": + self = .sideChat(try SideChatSource(from: decoder)) + default: + throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown ChatSource discriminant: \(discriminant)") + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .fork(let value): try value.encode(to: encoder) + case .sideChat(let value): try value.encode(to: encoder) + } + } +} + // MARK: - ReconnectResult Union public enum ReconnectResult: Codable, Sendable { diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 816b6db2c..077d633ac 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -97,6 +97,15 @@ public enum ChatOriginKind: String, Codable, Sendable { case tool = "tool" } +/// Discriminant for {@link ChatSourceTurn} — which kind of source-turn snapshot +/// a fork or side chat was created from. +public enum ChatSourceTurnKind: String, Codable, Sendable { + /// A completed turn retained in the source chat's `turns`. + case completed = "completed" + /// The source chat's current in-progress `activeTurn`. + case active = "active" +} + /// How a user can interact with a chat. /// /// - `Full` — user can send messages and watch (default when absent) @@ -667,8 +676,10 @@ public struct MultipleChatsCapability: Codable, Sendable { /// `kind: "sideChat"` to `createChat`. /// /// A side chat receives the source turn as context without copying the source - /// transcript into its own visible history. Side-chat support always implies - /// multi-chat support. + /// transcript into its own visible history. The source may be a completed turn + /// or the source chat's current active turn; when active, the host snapshots + /// the available partial assistant response at creation time. Side-chat + /// support always implies multi-chat support. public var sideChat: Bool? public init( @@ -1078,6 +1089,36 @@ public struct ChatSummary: Codable, Sendable { } } +public struct CompletedChatSourceTurn: Codable, Sendable { + /// Discriminant + public var kind: ChatSourceTurnKind + /// Turn identifier in the source chat. + public var turnId: String + + public init( + kind: ChatSourceTurnKind, + turnId: String + ) { + self.kind = kind + self.turnId = turnId + } +} + +public struct ActiveChatSourceTurn: Codable, Sendable { + /// Discriminant + public var kind: ChatSourceTurnKind + /// Active turn identifier in the source chat. + public var turnId: String + + public init( + kind: ChatSourceTurnKind, + turnId: String + ) { + self.kind = kind + self.turnId = turnId + } +} + public struct SessionState: Codable, Sendable { /// Agent provider ID public var provider: String @@ -5196,6 +5237,35 @@ public struct ResourceChange: Codable, Sendable { // MARK: - Discriminated Unions +public enum ChatSourceTurn: Codable, Sendable { + case completed(CompletedChatSourceTurn) + case active(ActiveChatSourceTurn) + + private enum DiscriminantKey: String, CodingKey { + case discriminant = "kind" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + let discriminant = try container.decode(String.self, forKey: .discriminant) + switch discriminant { + case "completed": + self = .completed(try CompletedChatSourceTurn(from: decoder)) + case "active": + self = .active(try ActiveChatSourceTurn(from: decoder)) + default: + throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown ChatSourceTurn discriminant: \(discriminant)") + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .completed(let value): try value.encode(to: encoder) + case .active(let value): try value.encode(to: encoder) + } + } +} + public struct ChatOriginUser: Codable, Sendable { public var kind: ChatOriginKind @@ -5207,12 +5277,12 @@ public struct ChatOriginUser: Codable, Sendable { public struct ChatOriginFork: Codable, Sendable { public var kind: ChatOriginKind public var chat: String - public var turnId: String + public var turn: CompletedChatSourceTurn - public init(kind: ChatOriginKind = .fork, chat: String, turnId: String) { + public init(kind: ChatOriginKind = .fork, chat: String, turn: CompletedChatSourceTurn) { self.kind = kind self.chat = chat - self.turnId = turnId + self.turn = turn } } @@ -5231,12 +5301,12 @@ public struct ChatOriginTool: Codable, Sendable { public struct ChatOriginSideChat: Codable, Sendable { public var kind: ChatOriginKind public var chat: String - public var turnId: String + public var turn: ChatSourceTurn - public init(kind: ChatOriginKind = .sideChat, chat: String, turnId: String) { + public init(kind: ChatOriginKind = .sideChat, chat: String, turn: ChatSourceTurn) { self.kind = kind self.chat = chat - self.turnId = turnId + self.turn = turn } } diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index 2a5eb33fa..4b09e4de6 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -149,7 +149,7 @@ ChatState { status: number // SessionStatus bitset activity?: string modifiedAt: string - origin?: ChatOrigin // how the chat came to exist (user / fork / tool) + origin?: ChatOrigin // how the chat came to exist (user / fork / sideChat / tool) workingDirectories?: URI[] // subset of session's workingDirectories primaryWorkingDirectory?: URI // this chat's primary, read-only (set at creation, when requiresPrimary) @@ -661,7 +661,8 @@ createChat({ Forked chats (`source.kind: "fork"`) inherit the source chat's `workingDirectories` and primary, so both fields are ignored for forks. Side -chats can still choose their own subset and primary. +chats can still choose their own subset and primary, whether they start from a +completed source turn or an active-turn snapshot. #### Managing the subset after creation diff --git a/docs/proposals/multi-chat.md b/docs/proposals/multi-chat.md index 6a9b12bf2..8fafb7005 100644 --- a/docs/proposals/multi-chat.md +++ b/docs/proposals/multi-chat.md @@ -430,8 +430,9 @@ a `copilot` per piece of work; `/resume` reopens one at a time): A new chat is forked from a point in an existing chat, seeded with that history, then diverges on its own. Both chats keep sharing the session's context. -A **side chat** starts from the same kind of `(chat, turn)` reference but does -not copy the parent's turns into its own transcript. It is a focused, +A **side chat** starts from the same kind of `(chat, turn)` reference — either a +completed turn or an active-turn snapshot — but does not copy the parent's turns +into its own transcript. It is a focused, independent conversation that can later be pulled back into the main chat as a bounded chat attachment. The distinction keeps the side transcript clean while making the result durable and reusable: @@ -439,7 +440,7 @@ making the result durable and reusable: | Mode | Source context | New chat's visible history | Return path | | --- | --- | --- | --- | | Fork | Copied through the source turn | Starts with copied parent turns | Continue either branch | -| Side chat | Supplied through the source turn | Starts empty | Attach through a completed side-chat turn | +| Side chat | Supplied through the source turn (completed or active snapshot) | Starts empty | Attach through a completed side-chat turn | Agents advertise these independently through `multipleChats.fork` and `multipleChats.sideChat`, so clients only offer creation modes the selected diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 3874e9b42..57bf50e19 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -62,7 +62,7 @@ Clients MAY periodically sync their local input state into the draft by dispatch [`createChat`](/reference/chat#createchat) is a JSON-RPC request. Callers identify the owning session via the request's `channel` parameter (`ahp-session:/`) and MAY supply: - an `initialMessage` to start the first turn immediately — carrying its own [`model`](/reference/chat#message) / [`agent`](/reference/chat#message) selection — and -- a `source` of type [`ChatSource`](/reference/chat#chatsource), whose required `kind` selects either a `fork` or `sideChat` from an existing chat at a specific completed turn. +- a `source` of type [`ChatSource`](/reference/chat#chatsource), whose required `kind` selects either a `fork` or `sideChat` from an existing chat at a specific source turn. The server allocates the chat URI and adds the chat to the session's catalog (`session/chatAdded` on the session channel) before returning. @@ -74,8 +74,9 @@ Clients MUST gate source-based creation using the selected Absence or `false` means the corresponding source kind is unsupported. The host MUST reject an unsupported source kind. It MUST also reject a source chat outside -the target session, an unknown source chat or turn, an active rather than -completed source turn, or a source that names the chat being created. +the target session, an unknown source chat or turn, or a source that names the +chat being created. For `source.kind: "fork"`, the host MUST additionally reject +`source.turn.kind: "active"` — forks only target completed turns. Forks and side chats use the source differently: @@ -83,8 +84,12 @@ Forks and side chats use the source differently: chat's visible `turns`, after which the chats diverge. - A **side chat** starts with its own empty visible history. The host supplies source history through the referenced turn as agent context, but does not copy - that history into the side chat's `turns`. An `initialMessage`, when supplied, - becomes the side chat's first visible turn. + that history into the side chat's `turns`. When `source.turn.kind` is + `"active"`, the host snapshots the source chat's retained history plus the + active turn's current user message and whatever assistant response parts are + already available when accepting `createChat`; later source-turn deltas do not + retroactively change the side chat's starting context. An `initialMessage`, + when supplied, becomes the side chat's first visible turn. ### Origin @@ -93,8 +98,8 @@ Each chat advertises how it came into existence via [`ChatOrigin`](/reference/ch | Kind | Meaning | |---|---| | `user` | User created the chat explicitly (e.g. via the host UI). | -| `fork` | Forked from an existing chat at a specific turn — payload references the source chat URI and turn id. | -| `sideChat` | Created as an independent side conversation using context through a specific source turn — payload references the source chat URI and turn id. | +| `fork` | Forked from an existing chat at a specific completed turn — payload references the source chat URI and source-turn snapshot. | +| `sideChat` | Created as an independent side conversation using context through a specific source turn — payload references the source chat URI and source-turn snapshot (completed or active). | | `tool` | Spawned by a tool call running in another chat — payload references the source chat URI and tool call id (e.g. a sub-agent delegation). | Clients MAY use the origin to render contextual UI (parent indicators, fork markers, "spawned by tool" badges), but origin is **not** a hierarchy — every chat is equally addressable. diff --git a/schema/actions.schema.json b/schema/actions.schema.json index b4befa031..57b7ccfdb 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -895,7 +895,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } } }, "required": [ @@ -2069,7 +2072,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/ResourceChange" + } } }, "required": [ @@ -2488,10 +2494,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -2504,10 +2510,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -2735,7 +2741,7 @@ }, "sideChat": { "type": "boolean", - "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. Side-chat support always implies\nmulti-chat support." + "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. The source may be a completed turn\nor the source chat's current active turn; when active, the host snapshots\nthe available partial assistant response at creation time. Side-chat\nsupport always implies multi-chat support." } } }, @@ -3439,13 +3445,22 @@ "type": "object", "properties": { "type": { - "type": "string" + "type": "string", + "enum": [ + "object" + ] }, "properties": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "object" + } }, "required": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -3457,13 +3472,22 @@ "type": "object", "properties": { "type": { - "type": "string" + "type": "string", + "enum": [ + "object" + ] }, "properties": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "object" + } }, "required": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -4315,7 +4339,8 @@ "type": "object", "properties": { "tools": { - "type": "string" + "type": "object", + "additionalProperties": {} } }, "description": "Producer serves `sampling/createMessage` via `mcpMethodCall`." @@ -4600,6 +4625,42 @@ "modifiedAt" ] }, + "CompletedChatSourceTurn": { + "type": "object", + "description": "A completed turn used as a chat source or recorded in chat origin.", + "properties": { + "kind": { + "const": "completed", + "description": "Discriminant" + }, + "turnId": { + "type": "string", + "description": "Turn identifier in the source chat." + } + }, + "required": [ + "kind", + "turnId" + ] + }, + "ActiveChatSourceTurn": { + "type": "object", + "description": "The current in-progress turn used as a side-chat source or recorded in chat\norigin.\n\nWhen a host accepts side-chat creation from an active turn, it snapshots the\nsource chat's retained history plus that turn's current user message and any\npartial assistant response already available. Later source-turn deltas do not\nretroactively change the created side chat's starting context.", + "properties": { + "kind": { + "const": "active", + "description": "Discriminant" + }, + "turnId": { + "type": "string", + "description": "Active turn identifier in the source chat." + } + }, + "required": [ + "kind", + "turnId" + ] + }, "PendingMessage": { "type": "object", "description": "A message queued for future delivery to the agent.\n\nSteering messages are injected into the current turn mid-flight.\nQueued messages are automatically started as new turns after the\ncurrent turn naturally finishes.", @@ -5885,7 +5946,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } } }, "required": [ @@ -6398,10 +6462,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -6414,10 +6478,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -7008,7 +7072,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -7020,7 +7087,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -7202,6 +7272,17 @@ ], "description": "Discriminated union of all MCP server lifecycle states.\nDiscriminated by `kind` (a {@link McpServerStatus} value)." }, + "ChatSourceTurn": { + "oneOf": [ + { + "$ref": "#/$defs/CompletedChatSourceTurn" + }, + { + "$ref": "#/$defs/ActiveChatSourceTurn" + } + ], + "description": "A source-turn reference used by chat creation and durable chat origin state." + }, "ChatOrigin": { "oneOf": [ { @@ -7222,16 +7303,16 @@ "const": "fork" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, - "turnId": { - "type": "string" + "turn": { + "$ref": "#/$defs/CompletedChatSourceTurn" } }, "required": [ "kind", "chat", - "turnId" + "turn" ] }, { @@ -7241,16 +7322,16 @@ "const": "sideChat" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, - "turnId": { - "type": "string" + "turn": { + "$ref": "#/$defs/ChatSourceTurn" } }, "required": [ "kind", "chat", - "turnId" + "turn" ] }, { @@ -7260,7 +7341,7 @@ "const": "tool" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, "toolCallId": { "type": "string" diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 8ab2742ca..309312b22 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -1100,27 +1100,50 @@ "items" ] }, - "ChatSource": { + "ForkChatSource": { "type": "object", - "description": "Identifies a source chat and completed turn for a new chat.", + "description": "Copies source history through a completed turn into the new chat.", "properties": { "kind": { - "$ref": "#/$defs/ChatSourceKind", - "description": "How the source is used." + "const": "fork", + "description": "Discriminant" }, "chat": { "$ref": "#/$defs/URI", "description": "URI of the existing source chat." }, - "turnId": { - "type": "string", - "description": "Completed turn in the source chat. For a fork, content through this turn is\ncopied. For a side chat, that content is supplied as context but is not\ncopied into the new chat's visible `turns`." + "turn": { + "$ref": "#/$defs/CompletedChatSourceTurn", + "description": "Completed turn in the source chat. Content through this turn is copied into\nthe new chat's visible `turns`." } }, "required": [ "kind", "chat", - "turnId" + "turn" + ] + }, + "SideChatSource": { + "type": "object", + "description": "Supplies source context to a new side chat without copying it into the side\nchat's visible history.", + "properties": { + "kind": { + "const": "sideChat", + "description": "Discriminant" + }, + "chat": { + "$ref": "#/$defs/URI", + "description": "URI of the existing source chat." + }, + "turn": { + "$ref": "#/$defs/ChatSourceTurn", + "description": "Source turn in the source chat.\n\nWhen this is `kind: \"completed\"`, the side chat is seeded from the source\nchat's retained history through that turn. When this is `kind: \"active\"`,\nthe host snapshots the source chat's retained history plus the active turn's\ncurrent user message and any partial assistant response already available\nwhen accepting `createChat`." + } + }, + "required": [ + "kind", + "chat", + "turn" ] }, "CreateChatParams": { @@ -1141,7 +1164,7 @@ }, "source": { "$ref": "#/$defs/ChatSource", - "description": "Optional source chat and completed turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`." + "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks require\n`source.turn.kind: \"completed\"`. Side chats accept either a completed turn\nor the source chat's current active turn." }, "workingDirectories": { "type": "array", @@ -1294,7 +1317,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -1306,7 +1332,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -1636,10 +1665,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -1652,10 +1681,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -1883,7 +1912,7 @@ }, "sideChat": { "type": "boolean", - "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. Side-chat support always implies\nmulti-chat support." + "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. The source may be a completed turn\nor the source chat's current active turn; when active, the host snapshots\nthe available partial assistant response at creation time. Side-chat\nsupport always implies multi-chat support." } } }, @@ -2587,13 +2616,22 @@ "type": "object", "properties": { "type": { - "type": "string" + "type": "string", + "enum": [ + "object" + ] }, "properties": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "object" + } }, "required": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -2605,13 +2643,22 @@ "type": "object", "properties": { "type": { - "type": "string" + "type": "string", + "enum": [ + "object" + ] }, "properties": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "object" + } }, "required": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -3463,7 +3510,8 @@ "type": "object", "properties": { "tools": { - "type": "string" + "type": "object", + "additionalProperties": {} } }, "description": "Producer serves `sampling/createMessage` via `mcpMethodCall`." @@ -3748,6 +3796,42 @@ "modifiedAt" ] }, + "CompletedChatSourceTurn": { + "type": "object", + "description": "A completed turn used as a chat source or recorded in chat origin.", + "properties": { + "kind": { + "const": "completed", + "description": "Discriminant" + }, + "turnId": { + "type": "string", + "description": "Turn identifier in the source chat." + } + }, + "required": [ + "kind", + "turnId" + ] + }, + "ActiveChatSourceTurn": { + "type": "object", + "description": "The current in-progress turn used as a side-chat source or recorded in chat\norigin.\n\nWhen a host accepts side-chat creation from an active turn, it snapshots the\nsource chat's retained history plus that turn's current user message and any\npartial assistant response already available. Later source-turn deltas do not\nretroactively change the created side chat's starting context.", + "properties": { + "kind": { + "const": "active", + "description": "Discriminant" + }, + "turnId": { + "type": "string", + "description": "Active turn identifier in the source chat." + } + }, + "required": [ + "kind", + "turnId" + ] + }, "PendingMessage": { "type": "object", "description": "A message queued for future delivery to the agent.\n\nSteering messages are injected into the current turn mid-flight.\nQueued messages are automatically started as new turns after the\ncurrent turn naturally finishes.", @@ -5033,7 +5117,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } } }, "required": [ @@ -5546,10 +5633,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -5562,10 +5649,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -6156,7 +6243,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -6168,7 +6258,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -7090,7 +7183,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } } }, "required": [ @@ -8264,7 +8360,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/ResourceChange" + } } }, "required": [ @@ -8595,13 +8694,27 @@ ], "description": "An attachment associated with a {@link Message}." }, - "ChatSourceKind": { - "enum": [ - "fork", - "sideChat" + "ChatSourceTurn": { + "oneOf": [ + { + "$ref": "#/$defs/CompletedChatSourceTurn" + }, + { + "$ref": "#/$defs/ActiveChatSourceTurn" + } ], - "type": "string", - "description": "How a new chat uses its source chat and turn." + "description": "A source-turn reference used by chat creation and durable chat origin state." + }, + "ChatSource": { + "oneOf": [ + { + "$ref": "#/$defs/ForkChatSource" + }, + { + "$ref": "#/$defs/SideChatSource" + } + ], + "description": "Identifies a source chat for a new chat." }, "TerminalClaim": { "oneOf": [ @@ -8623,10 +8736,14 @@ "const": "resource" }, "resource": { - "type": "string" + "$ref": "#/$defs/URI" }, "side": { - "type": "string" + "type": "string", + "enum": [ + "before", + "after" + ] } }, "required": [ @@ -8641,13 +8758,17 @@ "const": "range" }, "resource": { - "type": "string" + "$ref": "#/$defs/URI" }, "side": { - "type": "string" + "type": "string", + "enum": [ + "before", + "after" + ] }, "range": { - "type": "string" + "$ref": "#/$defs/TextRange" } }, "required": [ @@ -8905,16 +9026,16 @@ "const": "fork" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, - "turnId": { - "type": "string" + "turn": { + "$ref": "#/$defs/CompletedChatSourceTurn" } }, "required": [ "kind", "chat", - "turnId" + "turn" ] }, { @@ -8924,16 +9045,16 @@ "const": "sideChat" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, - "turnId": { - "type": "string" + "turn": { + "$ref": "#/$defs/ChatSourceTurn" } }, "required": [ "kind", "chat", - "turnId" + "turn" ] }, { @@ -8943,7 +9064,7 @@ "const": "tool" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, "toolCallId": { "type": "string" diff --git a/schema/errors.schema.json b/schema/errors.schema.json index cc1b12b2b..bc2357594 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -397,10 +397,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -413,10 +413,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -644,7 +644,7 @@ }, "sideChat": { "type": "boolean", - "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. Side-chat support always implies\nmulti-chat support." + "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. The source may be a completed turn\nor the source chat's current active turn; when active, the host snapshots\nthe available partial assistant response at creation time. Side-chat\nsupport always implies multi-chat support." } } }, @@ -1348,13 +1348,22 @@ "type": "object", "properties": { "type": { - "type": "string" + "type": "string", + "enum": [ + "object" + ] }, "properties": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "object" + } }, "required": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -1366,13 +1375,22 @@ "type": "object", "properties": { "type": { - "type": "string" + "type": "string", + "enum": [ + "object" + ] }, "properties": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "object" + } }, "required": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -2224,7 +2242,8 @@ "type": "object", "properties": { "tools": { - "type": "string" + "type": "object", + "additionalProperties": {} } }, "description": "Producer serves `sampling/createMessage` via `mcpMethodCall`." @@ -2509,6 +2528,42 @@ "modifiedAt" ] }, + "CompletedChatSourceTurn": { + "type": "object", + "description": "A completed turn used as a chat source or recorded in chat origin.", + "properties": { + "kind": { + "const": "completed", + "description": "Discriminant" + }, + "turnId": { + "type": "string", + "description": "Turn identifier in the source chat." + } + }, + "required": [ + "kind", + "turnId" + ] + }, + "ActiveChatSourceTurn": { + "type": "object", + "description": "The current in-progress turn used as a side-chat source or recorded in chat\norigin.\n\nWhen a host accepts side-chat creation from an active turn, it snapshots the\nsource chat's retained history plus that turn's current user message and any\npartial assistant response already available. Later source-turn deltas do not\nretroactively change the created side chat's starting context.", + "properties": { + "kind": { + "const": "active", + "description": "Discriminant" + }, + "turnId": { + "type": "string", + "description": "Active turn identifier in the source chat." + } + }, + "required": [ + "kind", + "turnId" + ] + }, "PendingMessage": { "type": "object", "description": "A message queued for future delivery to the agent.\n\nSteering messages are injected into the current turn mid-flight.\nQueued messages are automatically started as new turns after the\ncurrent turn naturally finishes.", @@ -3794,7 +3849,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } } }, "required": [ @@ -4307,10 +4365,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -4323,10 +4381,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -4917,7 +4975,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -4929,7 +4990,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -6056,27 +6120,50 @@ "items" ] }, - "ChatSource": { + "ForkChatSource": { "type": "object", - "description": "Identifies a source chat and completed turn for a new chat.", + "description": "Copies source history through a completed turn into the new chat.", "properties": { "kind": { - "$ref": "#/$defs/ChatSourceKind", - "description": "How the source is used." + "const": "fork", + "description": "Discriminant" }, "chat": { "$ref": "#/$defs/URI", "description": "URI of the existing source chat." }, - "turnId": { - "type": "string", - "description": "Completed turn in the source chat. For a fork, content through this turn is\ncopied. For a side chat, that content is supplied as context but is not\ncopied into the new chat's visible `turns`." + "turn": { + "$ref": "#/$defs/CompletedChatSourceTurn", + "description": "Completed turn in the source chat. Content through this turn is copied into\nthe new chat's visible `turns`." } }, "required": [ "kind", "chat", - "turnId" + "turn" + ] + }, + "SideChatSource": { + "type": "object", + "description": "Supplies source context to a new side chat without copying it into the side\nchat's visible history.", + "properties": { + "kind": { + "const": "sideChat", + "description": "Discriminant" + }, + "chat": { + "$ref": "#/$defs/URI", + "description": "URI of the existing source chat." + }, + "turn": { + "$ref": "#/$defs/ChatSourceTurn", + "description": "Source turn in the source chat.\n\nWhen this is `kind: \"completed\"`, the side chat is seeded from the source\nchat's retained history through that turn. When this is `kind: \"active\"`,\nthe host snapshots the source chat's retained history plus the active turn's\ncurrent user message and any partial assistant response already available\nwhen accepting `createChat`." + } + }, + "required": [ + "kind", + "chat", + "turn" ] }, "CreateChatParams": { @@ -6097,7 +6184,7 @@ }, "source": { "$ref": "#/$defs/ChatSource", - "description": "Optional source chat and completed turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`." + "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks require\n`source.turn.kind: \"completed\"`. Side chats accept either a completed turn\nor the source chat's current active turn." }, "workingDirectories": { "type": "array", @@ -6250,7 +6337,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -6262,7 +6352,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -6520,16 +6613,16 @@ "const": "fork" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, - "turnId": { - "type": "string" + "turn": { + "$ref": "#/$defs/CompletedChatSourceTurn" } }, "required": [ "kind", "chat", - "turnId" + "turn" ] }, { @@ -6539,16 +6632,16 @@ "const": "sideChat" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, - "turnId": { - "type": "string" + "turn": { + "$ref": "#/$defs/ChatSourceTurn" } }, "required": [ "kind", "chat", - "turnId" + "turn" ] }, { @@ -6558,7 +6651,7 @@ "const": "tool" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, "toolCallId": { "type": "string" @@ -7180,13 +7273,27 @@ "type": "string", "description": "The kind of completion items being requested." }, - "ChatSourceKind": { - "enum": [ - "fork", - "sideChat" + "ChatSourceTurn": { + "oneOf": [ + { + "$ref": "#/$defs/CompletedChatSourceTurn" + }, + { + "$ref": "#/$defs/ActiveChatSourceTurn" + } ], - "type": "string", - "description": "How a new chat uses its source chat and turn." + "description": "A source-turn reference used by chat creation and durable chat origin state." + }, + "ChatSource": { + "oneOf": [ + { + "$ref": "#/$defs/ForkChatSource" + }, + { + "$ref": "#/$defs/SideChatSource" + } + ], + "description": "Identifies a source chat for a new chat." }, "ChangesetOperationTarget": { "oneOf": [ @@ -7197,10 +7304,14 @@ "const": "resource" }, "resource": { - "type": "string" + "$ref": "#/$defs/URI" }, "side": { - "type": "string" + "type": "string", + "enum": [ + "before", + "after" + ] } }, "required": [ @@ -7215,13 +7326,17 @@ "const": "range" }, "resource": { - "type": "string" + "$ref": "#/$defs/URI" }, "side": { - "type": "string" + "type": "string", + "enum": [ + "before", + "after" + ] }, "range": { - "type": "string" + "$ref": "#/$defs/TextRange" } }, "required": [ @@ -8072,7 +8187,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } } }, "required": [ @@ -9158,7 +9276,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/ResourceChange" + } } }, "required": [ diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 220c64351..edce7e0fd 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -560,10 +560,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -576,10 +576,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -807,7 +807,7 @@ }, "sideChat": { "type": "boolean", - "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. Side-chat support always implies\nmulti-chat support." + "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. The source may be a completed turn\nor the source chat's current active turn; when active, the host snapshots\nthe available partial assistant response at creation time. Side-chat\nsupport always implies multi-chat support." } } }, @@ -1511,13 +1511,22 @@ "type": "object", "properties": { "type": { - "type": "string" + "type": "string", + "enum": [ + "object" + ] }, "properties": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "object" + } }, "required": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -1529,13 +1538,22 @@ "type": "object", "properties": { "type": { - "type": "string" + "type": "string", + "enum": [ + "object" + ] }, "properties": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "object" + } }, "required": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -2387,7 +2405,8 @@ "type": "object", "properties": { "tools": { - "type": "string" + "type": "object", + "additionalProperties": {} } }, "description": "Producer serves `sampling/createMessage` via `mcpMethodCall`." @@ -2672,6 +2691,42 @@ "modifiedAt" ] }, + "CompletedChatSourceTurn": { + "type": "object", + "description": "A completed turn used as a chat source or recorded in chat origin.", + "properties": { + "kind": { + "const": "completed", + "description": "Discriminant" + }, + "turnId": { + "type": "string", + "description": "Turn identifier in the source chat." + } + }, + "required": [ + "kind", + "turnId" + ] + }, + "ActiveChatSourceTurn": { + "type": "object", + "description": "The current in-progress turn used as a side-chat source or recorded in chat\norigin.\n\nWhen a host accepts side-chat creation from an active turn, it snapshots the\nsource chat's retained history plus that turn's current user message and any\npartial assistant response already available. Later source-turn deltas do not\nretroactively change the created side chat's starting context.", + "properties": { + "kind": { + "const": "active", + "description": "Discriminant" + }, + "turnId": { + "type": "string", + "description": "Active turn identifier in the source chat." + } + }, + "required": [ + "kind", + "turnId" + ] + }, "PendingMessage": { "type": "object", "description": "A message queued for future delivery to the agent.\n\nSteering messages are injected into the current turn mid-flight.\nQueued messages are automatically started as new turns after the\ncurrent turn naturally finishes.", @@ -3957,7 +4012,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } } }, "required": [ @@ -4470,10 +4528,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -4486,10 +4544,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -5080,7 +5138,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -5092,7 +5153,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -5363,16 +5427,16 @@ "const": "fork" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, - "turnId": { - "type": "string" + "turn": { + "$ref": "#/$defs/CompletedChatSourceTurn" } }, "required": [ "kind", "chat", - "turnId" + "turn" ] }, { @@ -5382,16 +5446,16 @@ "const": "sideChat" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, - "turnId": { - "type": "string" + "turn": { + "$ref": "#/$defs/ChatSourceTurn" } }, "required": [ "kind", "chat", - "turnId" + "turn" ] }, { @@ -5401,7 +5465,7 @@ "const": "tool" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, "toolCallId": { "type": "string" @@ -5701,6 +5765,17 @@ ], "type": "string", "description": "Discriminant for {@link ResourceChange.type}." + }, + "ChatSourceTurn": { + "oneOf": [ + { + "$ref": "#/$defs/CompletedChatSourceTurn" + }, + { + "$ref": "#/$defs/ActiveChatSourceTurn" + } + ], + "description": "A source-turn reference used by chat creation and durable chat origin state." } } } diff --git a/schema/state.schema.json b/schema/state.schema.json index 7f5268b77..fa46aa23c 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -308,10 +308,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -324,10 +324,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -555,7 +555,7 @@ }, "sideChat": { "type": "boolean", - "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. Side-chat support always implies\nmulti-chat support." + "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. The source may be a completed turn\nor the source chat's current active turn; when active, the host snapshots\nthe available partial assistant response at creation time. Side-chat\nsupport always implies multi-chat support." } } }, @@ -1259,13 +1259,22 @@ "type": "object", "properties": { "type": { - "type": "string" + "type": "string", + "enum": [ + "object" + ] }, "properties": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "object" + } }, "required": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -1277,13 +1286,22 @@ "type": "object", "properties": { "type": { - "type": "string" + "type": "string", + "enum": [ + "object" + ] }, "properties": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "object" + } }, "required": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -2135,7 +2153,8 @@ "type": "object", "properties": { "tools": { - "type": "string" + "type": "object", + "additionalProperties": {} } }, "description": "Producer serves `sampling/createMessage` via `mcpMethodCall`." @@ -2420,6 +2439,42 @@ "modifiedAt" ] }, + "CompletedChatSourceTurn": { + "type": "object", + "description": "A completed turn used as a chat source or recorded in chat origin.", + "properties": { + "kind": { + "const": "completed", + "description": "Discriminant" + }, + "turnId": { + "type": "string", + "description": "Turn identifier in the source chat." + } + }, + "required": [ + "kind", + "turnId" + ] + }, + "ActiveChatSourceTurn": { + "type": "object", + "description": "The current in-progress turn used as a side-chat source or recorded in chat\norigin.\n\nWhen a host accepts side-chat creation from an active turn, it snapshots the\nsource chat's retained history plus that turn's current user message and any\npartial assistant response already available. Later source-turn deltas do not\nretroactively change the created side chat's starting context.", + "properties": { + "kind": { + "const": "active", + "description": "Discriminant" + }, + "turnId": { + "type": "string", + "description": "Active turn identifier in the source chat." + } + }, + "required": [ + "kind", + "turnId" + ] + }, "PendingMessage": { "type": "object", "description": "A message queued for future delivery to the agent.\n\nSteering messages are injected into the current turn mid-flight.\nQueued messages are automatically started as new turns after the\ncurrent turn naturally finishes.", @@ -3705,7 +3760,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } } }, "required": [ @@ -4218,10 +4276,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -4234,10 +4292,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -4828,7 +4886,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -4840,7 +4901,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -5022,6 +5086,17 @@ ], "description": "Discriminated union of all MCP server lifecycle states.\nDiscriminated by `kind` (a {@link McpServerStatus} value)." }, + "ChatSourceTurn": { + "oneOf": [ + { + "$ref": "#/$defs/CompletedChatSourceTurn" + }, + { + "$ref": "#/$defs/ActiveChatSourceTurn" + } + ], + "description": "A source-turn reference used by chat creation and durable chat origin state." + }, "ChatOrigin": { "oneOf": [ { @@ -5042,16 +5117,16 @@ "const": "fork" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, - "turnId": { - "type": "string" + "turn": { + "$ref": "#/$defs/CompletedChatSourceTurn" } }, "required": [ "kind", "chat", - "turnId" + "turn" ] }, { @@ -5061,16 +5136,16 @@ "const": "sideChat" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, - "turnId": { - "type": "string" + "turn": { + "$ref": "#/$defs/ChatSourceTurn" } }, "required": [ "kind", "chat", - "turnId" + "turn" ] }, { @@ -5080,7 +5155,7 @@ "const": "tool" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, "toolCallId": { "type": "string" diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index b5b86c464..331a6b05f 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -641,7 +641,7 @@ function generateDiscriminatedUnion(cfg: UnionConfig): string { const STATE_ENUMS = [ 'PolicyState', 'SessionLifecycle', 'SessionStatus', - 'ChatOriginKind', 'ChatInteractivity', 'PendingMessageKind', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', + 'ChatOriginKind', 'ChatSourceTurnKind', 'ChatInteractivity', 'PendingMessageKind', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', 'ToolCallConfirmationReason', 'ToolCallRiskAssessmentKind', @@ -677,6 +677,8 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'ChangesSummary' }, { name: 'ChatState' }, { name: 'ChatSummary' }, + { name: 'CompletedChatSourceTurn' }, + { name: 'ActiveChatSourceTurn' }, { name: 'PendingMessage' }, { name: 'ProjectInfo' }, { name: 'SessionConfigPropertySchema' }, @@ -1025,7 +1027,7 @@ func (*ChatUserOrigin) isChatOrigin() {} type ChatForkOrigin struct { \tKind ChatOriginKind \`json:"kind"\` \tChat URI \`json:"chat"\` -\tTurnId string \`json:"turnId"\` +\tTurn CompletedChatSourceTurn \`json:"turn"\` } func (*ChatForkOrigin) isChatOrigin() {} @@ -1033,7 +1035,7 @@ func (*ChatForkOrigin) isChatOrigin() {} type ChatSideChatOrigin struct { \tKind ChatOriginKind \`json:"kind"\` \tChat URI \`json:"chat"\` -\tTurnId string \`json:"turnId"\` +\tTurn ChatSourceTurn \`json:"turn"\` } func (*ChatSideChatOrigin) isChatOrigin() {} @@ -1269,6 +1271,8 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(SESSION_INPUT_REQUEST_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(CHAT_SOURCE_TURN_UNION)); + lines.push(''); lines.push(generateChatOriginGo()); lines.push(''); lines.push(generateSnapshotState()); @@ -1481,7 +1485,7 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: str { name: 'SubscribeParams' }, { name: 'SubscribeView' }, { name: 'SubscriptionDeliveryOptions' }, { name: 'SubscribeResult' }, { name: 'SessionForkSource' }, { name: 'CreateSessionParams' }, { name: 'DisposeSessionParams' }, - { name: 'ChatSource' }, { name: 'CreateChatParams' }, { name: 'DisposeChatParams' }, + { name: 'ForkChatSource' }, { name: 'SideChatSource' }, { name: 'CreateChatParams' }, { name: 'DisposeChatParams' }, { name: 'ListSessionsParams' }, { name: 'ListSessionsResult' }, { name: 'ResourceReadParams' }, { name: 'ResourceReadResult' }, { name: 'ResourceWriteParams' }, { name: 'ResourceWriteResult' }, @@ -1516,6 +1520,26 @@ const RECONNECT_RESULT_UNION: UnionConfig = { ], }; +const CHAT_SOURCE_UNION: UnionConfig = { + name: 'ChatSource', + discriminantField: 'kind', + doc: 'ChatSource identifies how a new chat uses a source chat.', + variants: [ + { variantName: 'Fork', innerType: 'ForkChatSource', wireValue: 'fork' }, + { variantName: 'SideChat', innerType: 'SideChatSource', wireValue: 'sideChat' }, + ], +}; + +const CHAT_SOURCE_TURN_UNION: UnionConfig = { + name: 'ChatSourceTurn', + discriminantField: 'kind', + doc: 'ChatSourceTurn identifies whether a source-turn snapshot came from a completed or active turn.', + variants: [ + { variantName: 'Completed', innerType: 'CompletedChatSourceTurn', wireValue: 'completed' }, + { variantName: 'Active', innerType: 'ActiveChatSourceTurn', wireValue: 'active' }, + ], +}; + function generateChangesetOperationTargetGo(): string { return `// ChangesetOperationTarget identifies the file or range a // ChangesetOperation should act on. @@ -1609,6 +1633,10 @@ function generateCommandsFile(project: Project): string { } } + lines.push('// ─── ChatSource Union ─────────────────────────────────────────────────\n'); + lines.push(generateDiscriminatedUnion(CHAT_SOURCE_UNION)); + lines.push(''); + lines.push('// ─── ReconnectResult Union ────────────────────────────────────────────\n'); lines.push(generateDiscriminatedUnion(RECONNECT_RESULT_UNION)); lines.push(''); @@ -1964,7 +1992,9 @@ function checkExhaustiveness(project: Project): void { 'PingParams', 'TerminalClaim', 'TerminalContentPart', + 'ChatSourceTurn', 'ChatOrigin', + 'ChatSource', 'ChatInputQuestion', 'ChatInputAnswerValue', 'ChatInputAnswer', diff --git a/scripts/generate-json-schema.test.ts b/scripts/generate-json-schema.test.ts index 7f3871604..83bf1b252 100644 --- a/scripts/generate-json-schema.test.ts +++ b/scripts/generate-json-schema.test.ts @@ -102,6 +102,27 @@ describe('generated JSON schemas', () => { assert.deepEqual(kinds, ['user', 'fork', 'sideChat', 'tool']); }); + + it('references structured source-turn payloads from ChatOrigin', () => { + const defs = schema.$defs as Record>; + const chatOrigin = defs.ChatOrigin; + const branches = chatOrigin.oneOf as Array>; + const branchByKind = new Map( + branches.map((branch) => { + const properties = branch.properties as Record>; + return [properties.kind.const as string, properties]; + }), + ); + + assert.deepEqual( + branchByKind.get('fork')?.turn, + { $ref: '#/$defs/CompletedChatSourceTurn' }, + ); + assert.deepEqual( + branchByKind.get('sideChat')?.turn, + { $ref: '#/$defs/ChatSourceTurn' }, + ); + }); }); } }); diff --git a/scripts/generate-json-schema.ts b/scripts/generate-json-schema.ts index 9566851f0..e899ecb0e 100644 --- a/scripts/generate-json-schema.ts +++ b/scripts/generate-json-schema.ts @@ -271,8 +271,7 @@ function inlineObjectToSchema(text: string, project: Project): JsonSchema { if (match) { const [, name, optional, type] = match; const fieldType = type.trim(); - schema.properties![name] = - enumMemberToSchema(fieldType, project) ?? { type: mapSimpleType(fieldType) }; + schema.properties![name] = typeTextToSchema(fieldType, project); if (!optional) { schema.required!.push(name); } @@ -283,13 +282,6 @@ function inlineObjectToSchema(text: string, project: Project): JsonSchema { return schema; } -function mapSimpleType(t: string): string { - if (t === 'string') return 'string'; - if (t === 'number') return 'number'; - if (t === 'boolean') return 'boolean'; - return 'string'; // fallback -} - // ─── Interface → JSON Schema ───────────────────────────────────────────────── function interfaceToSchema(iface: InterfaceDeclaration, project: Project): JsonSchema { diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index bb6206eda..065283174 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -788,7 +788,7 @@ internal object ToolResultContentSerializer : KSerializer { const STATE_ENUMS = [ 'PolicyState', 'PendingMessageKind', 'SessionLifecycle', 'SessionStatus', - 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', + 'ChatOriginKind', 'ChatSourceTurnKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', 'ToolCallConfirmationReason', 'ToolCallRiskAssessmentKind', @@ -806,7 +806,7 @@ const STATE_STRUCTS = [ 'MultipleChatsCapability', 'MultipleWorkingDirectoriesCapability', 'SessionModelInfo', 'ModelSelection', 'AgentSelection', 'ConfigPropertySchema', 'ConfigSchema', - 'PendingMessage', 'ChatState', 'ChatSummary', 'SessionState', 'SessionActiveClient', + 'PendingMessage', 'ChatState', 'ChatSummary', 'CompletedChatSourceTurn', 'ActiveChatSourceTurn', 'SessionState', 'SessionActiveClient', 'SessionChatInputRequest', 'SessionToolConfirmationRequest', 'SessionToolClientExecutionRequest', 'SessionToolAuthenticationRequest', 'SessionSummary', 'ChangesSummary', 'ProjectInfo', 'SessionConfigState', 'Turn', 'ActiveTurn', 'Message', @@ -869,6 +869,15 @@ const RESPONSE_PART_UNION: UnionConfig = { unknown: true, }; +const CHAT_SOURCE_TURN_UNION: UnionConfig = { + name: 'ChatSourceTurn', + discriminantField: 'kind', + variants: [ + { caseName: 'Completed', structName: 'CompletedChatSourceTurn', discriminantValue: 'completed' }, + { caseName: 'Active', structName: 'ActiveChatSourceTurn', discriminantValue: 'active' }, + ], +}; + const TOOL_CALL_STATE_UNION: UnionConfig = { name: 'ToolCallState', discriminantField: 'status', @@ -933,7 +942,7 @@ data class ChatOriginUser( data class ChatOriginFork( val kind: ChatOriginKind = ChatOriginKind.FORK, val chat: String, - val turnId: String, + val turn: CompletedChatSourceTurn, ) @Serializable @@ -947,7 +956,7 @@ data class ChatOriginTool( data class ChatOriginSideChat( val kind: ChatOriginKind = ChatOriginKind.SIDE_CHAT, val chat: String, - val turnId: String, + val turn: ChatSourceTurn, ) internal object ChatOriginSerializer : KSerializer { @@ -1152,6 +1161,8 @@ function generateStateFile(project: Project): string { lines.push('// ─── Discriminated Unions ───────────────────────────────────────────────────'); lines.push(''); + lines.push(generateDiscriminatedUnion(CHAT_SOURCE_TURN_UNION)); + lines.push(''); lines.push(generateChatOriginKotlin()); lines.push(''); lines.push(generateDiscriminatedUnion(RESPONSE_PART_UNION)); @@ -1446,7 +1457,7 @@ const COMMAND_STRUCTS = [ 'ReconnectParams', 'ReconnectReplayResult', 'ReconnectSnapshotResult', 'SubscribeParams', 'SubscribeView', 'SubscriptionDeliveryOptions', 'SubscribeResult', 'SessionForkSource', 'CreateSessionParams', 'DisposeSessionParams', - 'ChatSource', 'CreateChatParams', 'DisposeChatParams', + 'ForkChatSource', 'SideChatSource', 'CreateChatParams', 'DisposeChatParams', 'ListSessionsParams', 'ListSessionsResult', 'ResourceReadParams', 'ResourceReadResult', 'ResourceWriteParams', 'ResourceWriteResult', @@ -1480,6 +1491,15 @@ const RECONNECT_RESULT_UNION: UnionConfig = { ], }; +const CHAT_SOURCE_UNION: UnionConfig = { + name: 'ChatSource', + discriminantField: 'kind', + variants: [ + { caseName: 'Fork', structName: 'ForkChatSource', discriminantValue: 'fork' }, + { caseName: 'SideChat', structName: 'SideChatSource', discriminantValue: 'sideChat' }, + ], +}; + /** * ChangesetOperationTarget — TS discriminated union over `{ kind: "resource" }` * and `{ kind: "range" }`. The variant structs are inline-only in TS (not @@ -1580,6 +1600,11 @@ function generateCommandsFile(project: Project): string { } } + lines.push('// ─── ChatSource Union ───────────────────────────────────────────────────────'); + lines.push(''); + lines.push(generateDiscriminatedUnion(CHAT_SOURCE_UNION)); + lines.push(''); + lines.push('// ─── ReconnectResult Union ──────────────────────────────────────────────────'); lines.push(''); lines.push(generateDiscriminatedUnion(RECONNECT_RESULT_UNION)); @@ -1948,7 +1973,9 @@ function checkExhaustiveness(project: Project): void { 'ChatInputQuestion', // CHAT_INPUT_QUESTION_UNION discriminated union 'ChatInputAnswerValue', // CHAT_INPUT_ANSWER_VALUE_UNION discriminated union 'ChatInputAnswer', // CHAT_INPUT_ANSWER_UNION discriminated union + 'ChatSourceTurn', // CHAT_SOURCE_TURN_UNION discriminated union 'ChatOrigin', // hand-generated union for inline variants + 'ChatSource', // CHAT_SOURCE_UNION discriminated union 'ChatToolCallApprovedAction', // merged into ChatToolCallConfirmedAction 'ChatToolCallDeniedAction', // merged into ChatToolCallConfirmedAction 'ChatToolCallConfirmedAction', // emitted as merged variant diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 483d7a884..851373a0f 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -631,6 +631,88 @@ function generateDiscriminatedUnion(cfg: UnionConfig): string { return lines.join('\n'); } +function generateValueRoutedDiscriminatedUnion(cfg: UnionConfig): string { + const lines: string[] = []; + if (cfg.doc) { + for (const d of cfg.doc.split('\n')) lines.push(`/// ${d.trimEnd()}`); + } + lines.push('#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]'); + lines.push('#[serde(try_from = "serde_json::Value", into = "serde_json::Value")]'); + lines.push(`pub enum ${cfg.name} {`); + + for (const v of cfg.variants) { + if (v.doc) { + for (const d of v.doc.split('\n')) lines.push(` /// ${d.trimEnd()}`); + } + if (v.isUnit) { + lines.push(` ${v.variantName},`); + } else { + const inner = v.boxed ? `Box<${v.innerType}>` : v.innerType; + lines.push(` ${v.variantName}(${inner}),`); + } + } + + if (cfg.unknown) { + lines.push(' /// Unknown or future variant — preserved as raw JSON for round-trip fidelity.'); + lines.push(' /// Reducers treat this as a no-op.'); + lines.push(' Unknown(serde_json::Value),'); + } + + lines.push('}'); + lines.push(''); + lines.push(`impl TryFrom for ${cfg.name} {`); + lines.push(' type Error = String;'); + lines.push(''); + lines.push(' fn try_from(value: serde_json::Value) -> Result {'); + lines.push(' let Some(object) = value.as_object() else {'); + lines.push(` return Err("${cfg.name} must be a JSON object".to_string());`); + lines.push(' };'); + lines.push(` let Some(kind) = object.get(${JSON.stringify(cfg.discriminantField)}).and_then(|value| value.as_str()) else {`); + lines.push(` return Err("${cfg.name} is missing a string ${cfg.discriminantField} discriminant".to_string());`); + lines.push(' };'); + lines.push(''); + lines.push(' match kind {'); + for (const v of cfg.variants) { + lines.push(` ${JSON.stringify(v.wireValue)} => {`); + if (v.isUnit) { + lines.push(` Ok(Self::${v.variantName})`); + } else { + const boxOpen = v.boxed ? 'Box::new(' : ''; + const boxClose = v.boxed ? ')' : ''; + lines.push(` serde_json::from_value(value).map(|inner| Self::${v.variantName}(${boxOpen}inner${boxClose})).map_err(|error| error.to_string())`); + } + lines.push(' }'); + } + if (cfg.unknown) { + lines.push(' _ => Ok(Self::Unknown(value)),'); + } else { + lines.push(` _ => Err(format!("unknown ${cfg.discriminantField}: {kind}")),`); + } + lines.push(' }'); + lines.push(' }'); + lines.push('}'); + lines.push(''); + lines.push(`impl From<${cfg.name}> for serde_json::Value {`); + lines.push(` fn from(value: ${cfg.name}) -> Self {`); + lines.push(' match value {'); + for (const v of cfg.variants) { + if (v.isUnit) { + lines.push(` ${cfg.name}::${v.variantName} => serde_json::json!({ ${JSON.stringify(cfg.discriminantField)}: ${JSON.stringify(v.wireValue)} }),`); + } else { + const innerPattern = v.boxed ? 'inner' : 'inner'; + const innerValue = v.boxed ? '*inner' : 'inner'; + lines.push(` ${cfg.name}::${v.variantName}(${innerPattern}) => serde_json::to_value(${innerValue}).expect("serializing ${cfg.name}::${v.variantName}"),`); + } + } + if (cfg.unknown) { + lines.push(` ${cfg.name}::Unknown(value) => value,`); + } + lines.push(' }'); + lines.push(' }'); + lines.push('}'); + return lines.join('\n'); +} + // ─── Interface → Rust Struct (auto) ────────────────────────────────────────── function generateStructFromInterface( @@ -651,7 +733,7 @@ function generateStructFromInterface( const STATE_ENUMS = [ 'PolicyState', 'PendingMessageKind', 'SessionLifecycle', 'SessionStatus', - 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', + 'ChatOriginKind', 'ChatSourceTurnKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', 'ToolCallConfirmationReason', 'ToolCallRiskAssessmentKind', @@ -701,6 +783,8 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'PendingMessage' }, { name: 'ChatState' }, { name: 'ChatSummary' }, + { name: 'CompletedChatSourceTurn' }, + { name: 'ActiveChatSourceTurn' }, { name: 'SessionState' }, { name: 'SessionActiveClient' }, { name: 'SessionChatInputRequest', omitDiscriminants: true }, @@ -1056,18 +1140,16 @@ pub enum ChatOrigin { Fork { /// URI of the chat this one was forked from. chat: Uri, - /// Turn the fork was taken from. - #[serde(rename = "turnId")] - turn_id: String, + /// Completed source-turn snapshot the fork was taken from. + turn: CompletedChatSourceTurn, }, /// Independent side conversation created from a specific turn. #[serde(rename = "sideChat")] SideChat { /// URI of the chat that supplied the side-chat context. chat: Uri, - /// Turn through which context was supplied. - #[serde(rename = "turnId")] - turn_id: String, + /// Source-turn snapshot through which context was supplied. + turn: ChatSourceTurn, }, /// Spawned by a tool call in another chat. #[serde(rename = "tool")] @@ -1085,6 +1167,16 @@ pub enum ChatOrigin { }`; } +const CHAT_SOURCE_TURN_UNION: UnionConfig = { + name: 'ChatSourceTurn', + discriminantField: 'kind', + doc: 'A source-turn snapshot used when creating or describing a chat.', + variants: [ + { variantName: 'Completed', innerType: 'CompletedChatSourceTurn', wireValue: 'completed' }, + { variantName: 'Active', innerType: 'ActiveChatSourceTurn', wireValue: 'active' }, + ], +}; + function generateSnapshotState(): string { return `/// The state payload of a snapshot — root, session, chat, terminal, /// changeset, resource-watch, or annotations state. @@ -1140,6 +1232,8 @@ function generateStateFile(project: Project): string { } lines.push('// ─── Discriminated Unions ─────────────────────────────────────────────\n'); + lines.push(generateValueRoutedDiscriminatedUnion(CHAT_SOURCE_TURN_UNION)); + lines.push(''); lines.push(generateChatOrigin()); lines.push(''); lines.push(generateDiscriminatedUnion(RESPONSE_PART_UNION)); @@ -1425,7 +1519,7 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: s { name: 'SubscribeParams' }, { name: 'SubscribeView' }, { name: 'SubscriptionDeliveryOptions' }, { name: 'SubscribeResult' }, { name: 'SessionForkSource' }, { name: 'CreateSessionParams' }, { name: 'DisposeSessionParams' }, - { name: 'ChatSource' }, { name: 'CreateChatParams' }, + { name: 'ForkChatSource' }, { name: 'SideChatSource' }, { name: 'CreateChatParams' }, { name: 'DisposeChatParams' }, { name: 'ListSessionsParams' }, { name: 'ListSessionsResult' }, { name: 'ResourceReadParams' }, { name: 'ResourceReadResult' }, @@ -1461,12 +1555,22 @@ const RECONNECT_RESULT_UNION: UnionConfig = { ], }; +const CHAT_SOURCE_UNION: UnionConfig = { + name: 'ChatSource', + discriminantField: 'kind', + doc: 'How a new chat uses a source chat.', + variants: [ + { variantName: 'Fork', innerType: 'ForkChatSource', wireValue: 'fork' }, + { variantName: 'SideChat', innerType: 'SideChatSource', wireValue: 'sideChat' }, + ], +}; + function generateCommandsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); lines.push('use crate::actions::{ActionEnvelope, StateAction};'); lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentSelection, ContentRef, Message, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn};'); + lines.push('use crate::state::{AgentSelection, ChatSourceTurn, CompletedChatSourceTurn, ContentRef, Message, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn};'); lines.push(''); lines.push('// ─── Enums ────────────────────────────────────────────────────────────\n'); @@ -1498,6 +1602,10 @@ function generateCommandsFile(project: Project): string { } } + lines.push('// ─── ChatSource Union ─────────────────────────────────────────────────\n'); + lines.push(generateValueRoutedDiscriminatedUnion(CHAT_SOURCE_UNION)); + lines.push(''); + lines.push('// ─── ReconnectResult Union ────────────────────────────────────────────\n'); lines.push(generateDiscriminatedUnion(RECONNECT_RESULT_UNION)); lines.push(''); @@ -1864,7 +1972,9 @@ function checkExhaustiveness(project: Project): void { 'ChatToolCallDeniedAction', // merged into ChatToolCallConfirmedAction 'ChatToolCallConfirmedAction', // emitted as merged variant 'ChatAction', // source-only union covered by StateAction + 'ChatSourceTurn', 'ChatOrigin', // hand-generated union for inline variants + 'ChatSource', 'PingParams', 'TerminalClaim', 'TerminalContentPart', diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index f5b8d119b..f8ad4e6a9 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -538,7 +538,7 @@ function generatePartialStructFromInterface( const STATE_ENUMS = [ 'PolicyState', 'PendingMessageKind', 'SessionLifecycle', 'SessionStatus', - 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', + 'ChatOriginKind', 'ChatSourceTurnKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', 'ToolCallConfirmationReason', 'ToolCallRiskAssessmentKind', @@ -556,7 +556,7 @@ const STATE_STRUCTS = [ 'MultipleChatsCapability', 'MultipleWorkingDirectoriesCapability', 'SessionModelInfo', 'ModelSelection', 'AgentSelection', 'ConfigPropertySchema', 'ConfigSchema', - 'PendingMessage', 'ChatState', 'ChatSummary', 'SessionState', 'SessionActiveClient', + 'PendingMessage', 'ChatState', 'ChatSummary', 'CompletedChatSourceTurn', 'ActiveChatSourceTurn', 'SessionState', 'SessionActiveClient', 'SessionChatInputRequest', 'SessionToolConfirmationRequest', 'SessionToolClientExecutionRequest', 'SessionToolAuthenticationRequest', 'SessionSummary', 'ChangesSummary', 'ProjectInfo', 'SessionConfigState', 'Turn', 'ActiveTurn', 'Message', @@ -623,6 +623,15 @@ const RESPONSE_PART_UNION: UnionConfig = { ], }; +const CHAT_SOURCE_TURN_UNION: UnionConfig = { + name: 'ChatSourceTurn', + discriminantField: 'kind', + variants: [ + { caseName: 'completed', structName: 'CompletedChatSourceTurn', discriminantValue: 'completed' }, + { caseName: 'active', structName: 'ActiveChatSourceTurn', discriminantValue: 'active' }, + ], +}; + const TOOL_CALL_STATE_UNION: UnionConfig = { name: 'ToolCallState', discriminantField: 'status', @@ -969,12 +978,12 @@ function generateChatOriginSwift(): string { public struct ChatOriginFork: Codable, Sendable { public var kind: ChatOriginKind public var chat: String - public var turnId: String + public var turn: CompletedChatSourceTurn - public init(kind: ChatOriginKind = .fork, chat: String, turnId: String) { + public init(kind: ChatOriginKind = .fork, chat: String, turn: CompletedChatSourceTurn) { self.kind = kind self.chat = chat - self.turnId = turnId + self.turn = turn } } @@ -993,12 +1002,12 @@ public struct ChatOriginTool: Codable, Sendable { public struct ChatOriginSideChat: Codable, Sendable { public var kind: ChatOriginKind public var chat: String - public var turnId: String + public var turn: ChatSourceTurn - public init(kind: ChatOriginKind = .sideChat, chat: String, turnId: String) { + public init(kind: ChatOriginKind = .sideChat, chat: String, turn: ChatSourceTurn) { self.kind = kind self.chat = chat - self.turnId = turnId + self.turn = turn } } @@ -1065,6 +1074,8 @@ function generateStateFile(project: Project): string { } lines.push('// MARK: - Discriminated Unions\n'); + lines.push(generateDiscriminatedUnion(CHAT_SOURCE_TURN_UNION)); + lines.push(''); lines.push(generateChatOriginSwift()); lines.push(''); lines.push(generateDiscriminatedUnion(RESPONSE_PART_UNION)); @@ -1369,7 +1380,7 @@ const COMMAND_STRUCTS = [ 'ReconnectParams', 'ReconnectReplayResult', 'ReconnectSnapshotResult', 'SubscribeParams', 'SubscribeView', 'SubscriptionDeliveryOptions', 'SubscribeResult', 'SessionForkSource', 'CreateSessionParams', 'DisposeSessionParams', - 'ChatSource', 'CreateChatParams', 'DisposeChatParams', + 'ForkChatSource', 'SideChatSource', 'CreateChatParams', 'DisposeChatParams', 'ListSessionsParams', 'ListSessionsResult', 'ResourceReadParams', 'ResourceReadResult', 'ResourceWriteParams', 'ResourceWriteResult', @@ -1403,6 +1414,15 @@ const RECONNECT_RESULT_UNION: UnionConfig = { ], }; +const CHAT_SOURCE_UNION: UnionConfig = { + name: 'ChatSource', + discriminantField: 'kind', + variants: [ + { caseName: 'fork', structName: 'ForkChatSource', discriminantValue: 'fork' }, + { caseName: 'sideChat', structName: 'SideChatSource', discriminantValue: 'sideChat' }, + ], +}; + function generateCommandsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; @@ -1430,6 +1450,10 @@ function generateCommandsFile(project: Project): string { } } + lines.push('// MARK: - Command Unions\n'); + lines.push(generateDiscriminatedUnion(CHAT_SOURCE_UNION)); + lines.push(''); + lines.push('// MARK: - ReconnectResult Union\n'); lines.push(generateDiscriminatedUnion(RECONNECT_RESULT_UNION)); lines.push(''); @@ -1990,7 +2014,9 @@ function checkExhaustiveness(project: Project): void { 'ChatInputQuestion', // SESSION_INPUT_QUESTION_UNION discriminated union 'ChatInputAnswerValue', // SESSION_INPUT_ANSWER_VALUE_UNION discriminated union 'ChatInputAnswer', // CHAT_INPUT_ANSWER_UNION discriminated union + 'ChatSourceTurn', // CHAT_SOURCE_TURN_UNION discriminated union 'ChatOrigin', // hand-generated union for inline variants + 'ChatSource', // CHAT_SOURCE_UNION discriminated union 'ChatToolCallApprovedAction', 'ChatToolCallDeniedAction', 'ChatToolCallConfirmedAction', diff --git a/types/channels-chat/commands.ts b/types/channels-chat/commands.ts index 46000fc65..823f9b8b3 100644 --- a/types/channels-chat/commands.ts +++ b/types/channels-chat/commands.ts @@ -6,7 +6,11 @@ import type { URI } from '../common/state.js'; import type { BaseParams } from '../common/commands.js'; -import type { Message } from './state.js'; +import type { + ChatSourceTurn, + CompletedChatSourceTurn, + Message, +} from './state.js'; // ─── createChat ────────────────────────────────────────────────────────────── @@ -21,22 +25,48 @@ export const enum ChatSourceKind { } /** - * Identifies a source chat and completed turn for a new chat. - * + * Copies source history through a completed turn into the new chat. */ -export interface ChatSource { - /** How the source is used. */ - kind: ChatSourceKind; +export interface ForkChatSource { + /** Discriminant */ + kind: ChatSourceKind.Fork; /** URI of the existing source chat. */ chat: URI; /** - * Completed turn in the source chat. For a fork, content through this turn is - * copied. For a side chat, that content is supplied as context but is not - * copied into the new chat's visible `turns`. + * Completed turn in the source chat. Content through this turn is copied into + * the new chat's visible `turns`. */ - turnId: string; + turn: CompletedChatSourceTurn; } +/** + * Supplies source context to a new side chat without copying it into the side + * chat's visible history. + */ +export interface SideChatSource { + /** Discriminant */ + kind: ChatSourceKind.SideChat; + /** URI of the existing source chat. */ + chat: URI; + /** + * Source turn in the source chat. + * + * When this is `kind: "completed"`, the side chat is seeded from the source + * chat's retained history through that turn. When this is `kind: "active"`, + * the host snapshots the source chat's retained history plus the active turn's + * current user message and any partial assistant response already available + * when accepting `createChat`. + */ + turn: ChatSourceTurn; +} + +/** + * Identifies a source chat for a new chat. + */ +export type ChatSource = + | ForkChatSource + | SideChatSource; + /** * Creates a new chat within a session. * @@ -54,13 +84,15 @@ export interface CreateChatParams extends BaseParams { /** Optional initial message for the new chat. */ initialMessage?: Message; /** - * Optional source chat and completed turn. + * Optional source chat and source turn. * * The source chat MUST belong to this session. Clients MUST only request * `kind: "fork"` when the selected agent advertises * `capabilities.multipleChats.fork`, and * `kind: "sideChat"` when the selected agent advertises - * `capabilities.multipleChats.sideChat`. + * `capabilities.multipleChats.sideChat`. Forks require + * `source.turn.kind: "completed"`. Side chats accept either a completed turn + * or the source chat's current active turn. */ source?: ChatSource; /** diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index a409377a0..b91434fed 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -181,6 +181,58 @@ export const enum ChatOriginKind { Tool = 'tool', } +/** + * Discriminant for {@link ChatSourceTurn} — which kind of source-turn snapshot + * a fork or side chat was created from. + * + * @category Chat State + */ +export const enum ChatSourceTurnKind { + /** A completed turn retained in the source chat's `turns`. */ + Completed = 'completed', + /** The source chat's current in-progress `activeTurn`. */ + Active = 'active', +} + +/** + * A completed turn used as a chat source or recorded in chat origin. + * + * @category Chat State + */ +export interface CompletedChatSourceTurn { + /** Discriminant */ + kind: ChatSourceTurnKind.Completed; + /** Turn identifier in the source chat. */ + turnId: string; +} + +/** + * The current in-progress turn used as a side-chat source or recorded in chat + * origin. + * + * When a host accepts side-chat creation from an active turn, it snapshots the + * source chat's retained history plus that turn's current user message and any + * partial assistant response already available. Later source-turn deltas do not + * retroactively change the created side chat's starting context. + * + * @category Chat State + */ +export interface ActiveChatSourceTurn { + /** Discriminant */ + kind: ChatSourceTurnKind.Active; + /** Active turn identifier in the source chat. */ + turnId: string; +} + +/** + * A source-turn reference used by chat creation and durable chat origin state. + * + * @category Chat State + */ +export type ChatSourceTurn = + | CompletedChatSourceTurn + | ActiveChatSourceTurn; + /** * How a chat came into existence. Clients MAY use it to render * contextual UI (parent indicators, fork markers, "spawned by tool" badges). @@ -195,8 +247,8 @@ export const enum ChatOriginKind { */ export type ChatOrigin = | { kind: ChatOriginKind.User } - | { kind: ChatOriginKind.Fork; chat: URI; turnId: string } - | { kind: ChatOriginKind.SideChat; chat: URI; turnId: string } + | { kind: ChatOriginKind.Fork; chat: URI; turn: CompletedChatSourceTurn } + | { kind: ChatOriginKind.SideChat; chat: URI; turn: ChatSourceTurn } | { kind: ChatOriginKind.Tool; chat: URI; toolCallId: string }; /** diff --git a/types/channels-root/state.ts b/types/channels-root/state.ts index fd6c20b12..d58b05f7a 100644 --- a/types/channels-root/state.ts +++ b/types/channels-root/state.ts @@ -143,8 +143,10 @@ export interface MultipleChatsCapability { * `kind: "sideChat"` to `createChat`. * * A side chat receives the source turn as context without copying the source - * transcript into its own visible history. Side-chat support always implies - * multi-chat support. + * transcript into its own visible history. The source may be a completed turn + * or the source chat's current active turn; when active, the host snapshots + * the available partial assistant response at creation time. Side-chat + * support always implies multi-chat support. */ sideChat?: boolean; } diff --git a/types/index.ts b/types/index.ts index a6ab6f6e9..604e27646 100644 --- a/types/index.ts +++ b/types/index.ts @@ -26,6 +26,9 @@ export type { ChatState, ChatSummary, ChatOrigin, + ChatSourceTurn, + CompletedChatSourceTurn, + ActiveChatSourceTurn, ChangesSummary, SessionConfigState, Turn, @@ -129,6 +132,7 @@ export { SessionStatus, SessionInputRequestKind, ChatOriginKind, + ChatSourceTurnKind, TurnState, MessageKind, MessageAttachmentKind, @@ -289,6 +293,8 @@ export type { DisposeSessionParams, CreateChatParams, ChatSource, + ForkChatSource, + SideChatSource, DisposeChatParams, CreateTerminalParams, DisposeTerminalParams, diff --git a/types/test-cases/reducers/172-session-chatremoved.json b/types/test-cases/reducers/172-session-chatremoved.json index 8ade791c0..ffd6e28f4 100644 --- a/types/test-cases/reducers/172-session-chatremoved.json +++ b/types/test-cases/reducers/172-session-chatremoved.json @@ -26,7 +26,10 @@ "origin": { "kind": "fork", "chat": "ahp-chat:/c1", - "turnId": "t1" + "turn": { + "kind": "completed", + "turnId": "t1" + } } } ], @@ -54,7 +57,10 @@ "origin": { "kind": "fork", "chat": "ahp-chat:/c1", - "turnId": "t1" + "turn": { + "kind": "completed", + "turnId": "t1" + } } } ] diff --git a/types/test-cases/round-trips/031-side-chat-origin.json b/types/test-cases/round-trips/031-side-chat-origin.json index edf1c4929..2702ce65f 100644 --- a/types/test-cases/round-trips/031-side-chat-origin.json +++ b/types/test-cases/round-trips/031-side-chat-origin.json @@ -1,7 +1,7 @@ { "name": "side-chat-origin", "group": "A", - "description": "A session/chatAdded action preserves side-chat provenance through the ChatOrigin discriminated union.", + "description": "A session/chatAdded action preserves completed-turn side-chat provenance through the ChatOrigin discriminated union.", "type": "StateAction", "input": { "type": "session/chatAdded", @@ -13,7 +13,10 @@ "origin": { "kind": "sideChat", "chat": "ahp-chat:/main", - "turnId": "turn-12" + "turn": { + "kind": "completed", + "turnId": "turn-12" + } } } }, @@ -28,7 +31,10 @@ "origin": { "kind": "sideChat", "chat": "ahp-chat:/main", - "turnId": "turn-12" + "turn": { + "kind": "completed", + "turnId": "turn-12" + } } } } diff --git a/types/test-cases/round-trips/033-chat-source-side-chat.json b/types/test-cases/round-trips/033-chat-source-side-chat.json index 2a0ead39b..e8eda37e2 100644 --- a/types/test-cases/round-trips/033-chat-source-side-chat.json +++ b/types/test-cases/round-trips/033-chat-source-side-chat.json @@ -1,18 +1,24 @@ { "name": "chat-source-side-chat", "group": "A", - "description": "ChatSource requires an explicit sideChat discriminator alongside its source chat and completed turn.", + "description": "ChatSource requires an explicit sideChat discriminator alongside its source chat and completed-turn snapshot.", "type": "ChatSource", "input": { "kind": "sideChat", "chat": "ahp-chat:/main", - "turnId": "turn-12" + "turn": { + "kind": "completed", + "turnId": "turn-12" + } }, "acceptableOutputs": [ { "kind": "sideChat", "chat": "ahp-chat:/main", - "turnId": "turn-12" + "turn": { + "kind": "completed", + "turnId": "turn-12" + } } ] } diff --git a/types/test-cases/round-trips/034-side-chat-origin-active.json b/types/test-cases/round-trips/034-side-chat-origin-active.json new file mode 100644 index 000000000..e35a8703d --- /dev/null +++ b/types/test-cases/round-trips/034-side-chat-origin-active.json @@ -0,0 +1,42 @@ +{ + "name": "side-chat-origin-active", + "group": "A", + "description": "A session/chatAdded action preserves active-turn side-chat provenance through the ChatOrigin discriminated union.", + "type": "StateAction", + "input": { + "type": "session/chatAdded", + "summary": { + "resource": "ahp-chat:/side-active", + "title": "Side investigation", + "status": 1, + "modifiedAt": "2026-07-22T20:00:00.000Z", + "origin": { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turn": { + "kind": "active", + "turnId": "turn-active" + } + } + } + }, + "acceptableOutputs": [ + { + "type": "session/chatAdded", + "summary": { + "resource": "ahp-chat:/side-active", + "title": "Side investigation", + "status": 1, + "modifiedAt": "2026-07-22T20:00:00.000Z", + "origin": { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turn": { + "kind": "active", + "turnId": "turn-active" + } + } + } + } + ] +} diff --git a/types/test-cases/round-trips/035-chat-source-side-chat-active.json b/types/test-cases/round-trips/035-chat-source-side-chat-active.json new file mode 100644 index 000000000..4038b232f --- /dev/null +++ b/types/test-cases/round-trips/035-chat-source-side-chat-active.json @@ -0,0 +1,24 @@ +{ + "name": "chat-source-side-chat-active", + "group": "A", + "description": "ChatSource allows an explicit active-turn sideChat source without weakening the completed-turn fork contract.", + "type": "ChatSource", + "input": { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turn": { + "kind": "active", + "turnId": "turn-active" + } + }, + "acceptableOutputs": [ + { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turn": { + "kind": "active", + "turnId": "turn-active" + } + } + ] +} From 3a085b090163e7a43a1bbf4cad4dbc7564fc3811 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 22 Jul 2026 15:30:32 -0700 Subject: [PATCH 3/9] chat: preserve fork source compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahptypes/commands.generated.go | 16 +++++---- clients/go/ahptypes/state.generated.go | 6 ++-- .../generated/Commands.generated.kt | 16 +++++---- .../generated/State.generated.kt | 2 +- clients/rust/crates/ahp-types/src/commands.rs | 16 +++++---- clients/rust/crates/ahp-types/src/state.rs | 5 +-- .../Generated/Commands.generated.swift | 20 ++++++----- .../Generated/State.generated.swift | 6 ++-- docs/specification/chat-channel.md | 3 +- schema/actions.schema.json | 8 ++--- schema/commands.schema.json | 18 +++++----- schema/errors.schema.json | 18 +++++----- schema/notifications.schema.json | 8 ++--- schema/state.schema.json | 8 ++--- scripts/generate-go.ts | 2 +- scripts/generate-json-schema.test.ts | 22 +++++++++--- scripts/generate-kotlin.ts | 2 +- scripts/generate-rust.ts | 5 +-- scripts/generate-swift.ts | 6 ++-- types/channels-chat/commands.ts | 17 +++++---- types/channels-chat/state.ts | 6 +++- .../reducers/172-session-chatremoved.json | 10 ++---- .../036-chat-source-fork-flat.json | 18 ++++++++++ .../037-chat-origin-fork-flat.json | 36 +++++++++++++++++++ 24 files changed, 180 insertions(+), 94 deletions(-) create mode 100644 types/test-cases/round-trips/036-chat-source-fork-flat.json create mode 100644 types/test-cases/round-trips/037-chat-origin-fork-flat.json diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index 7f6485e6c..6b4c90516 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -390,9 +390,12 @@ type ForkChatSource struct { Kind ChatSourceKind `json:"kind"` // URI of the existing source chat. Chat URI `json:"chat"` - // Completed turn in the source chat. Content through this turn is copied into - // the new chat's visible `turns`. - Turn CompletedChatSourceTurn `json:"turn"` + // Completed turn identifier in the source chat. + // + // Content through this turn is copied into the new chat's visible `turns`. + // This preserves the existing 0.7.x flat fork wire format (`chat` + + // `turnId`). + TurnId string `json:"turnId"` } // Supplies source context to a new side chat without copying it into the side @@ -426,9 +429,10 @@ type CreateChatParams struct { // `kind: "fork"` when the selected agent advertises // `capabilities.multipleChats.fork`, and // `kind: "sideChat"` when the selected agent advertises - // `capabilities.multipleChats.sideChat`. Forks require - // `source.turn.kind: "completed"`. Side chats accept either a completed turn - // or the source chat's current active turn. + // `capabilities.multipleChats.sideChat`. Forks keep the legacy flat + // `chat` + `turnId` shape and therefore only target completed turns. Side + // chats use `source.turn.kind` to distinguish a completed source turn from + // the source chat's current active turn. Source *ChatSource `json:"source,omitempty"` // Initial working-directory subset for this chat. Every entry MUST be // present in the owning session's `workingDirectories`; the server MUST diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 03f5a4445..003740765 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -4880,9 +4880,9 @@ type ChatUserOrigin struct { func (*ChatUserOrigin) isChatOrigin() {} type ChatForkOrigin struct { - Kind ChatOriginKind `json:"kind"` - Chat URI `json:"chat"` - Turn CompletedChatSourceTurn `json:"turn"` + Kind ChatOriginKind `json:"kind"` + Chat URI `json:"chat"` + TurnId string `json:"turnId"` } func (*ChatForkOrigin) isChatOrigin() {} 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 a3f270238..3c21312aa 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 @@ -477,10 +477,13 @@ data class ForkChatSource( */ val chat: String, /** - * Completed turn in the source chat. Content through this turn is copied into - * the new chat's visible `turns`. + * Completed turn identifier in the source chat. + * + * Content through this turn is copied into the new chat's visible `turns`. + * This preserves the existing 0.7.x flat fork wire format (`chat` + + * `turnId`). */ - val turn: CompletedChatSourceTurn + val turnId: String ) @Serializable @@ -526,9 +529,10 @@ data class CreateChatParams( * `kind: "fork"` when the selected agent advertises * `capabilities.multipleChats.fork`, and * `kind: "sideChat"` when the selected agent advertises - * `capabilities.multipleChats.sideChat`. Forks require - * `source.turn.kind: "completed"`. Side chats accept either a completed turn - * or the source chat's current active turn. + * `capabilities.multipleChats.sideChat`. Forks keep the legacy flat + * `chat` + `turnId` shape and therefore only target completed turns. Side + * chats use `source.turn.kind` to distinguish a completed source turn from + * the source chat's current active turn. */ val source: ChatSource? = null, /** 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 906063b16..e023ec325 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 @@ -4732,7 +4732,7 @@ data class ChatOriginUser( data class ChatOriginFork( val kind: ChatOriginKind = ChatOriginKind.FORK, val chat: String, - val turn: CompletedChatSourceTurn, + val turnId: String, ) @Serializable diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index 1f83ae382..b49141ed2 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -487,9 +487,12 @@ pub struct ForkChatSource { pub kind: ChatSourceKind, /// URI of the existing source chat. pub chat: Uri, - /// Completed turn in the source chat. Content through this turn is copied into - /// the new chat's visible `turns`. - pub turn: CompletedChatSourceTurn, + /// Completed turn identifier in the source chat. + /// + /// Content through this turn is copied into the new chat's visible `turns`. + /// This preserves the existing 0.7.x flat fork wire format (`chat` + + /// `turnId`). + pub turn_id: String, } /// Supplies source context to a new side chat without copying it into the side @@ -528,9 +531,10 @@ pub struct CreateChatParams { /// `kind: "fork"` when the selected agent advertises /// `capabilities.multipleChats.fork`, and /// `kind: "sideChat"` when the selected agent advertises - /// `capabilities.multipleChats.sideChat`. Forks require - /// `source.turn.kind: "completed"`. Side chats accept either a completed turn - /// or the source chat's current active turn. + /// `capabilities.multipleChats.sideChat`. Forks keep the legacy flat + /// `chat` + `turnId` shape and therefore only target completed turns. Side + /// chats use `source.turn.kind` to distinguish a completed source turn from + /// the source chat's current active turn. #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, /// Initial working-directory subset for this chat. Every entry MUST be diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 3f25479bc..1998c53c6 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -4312,8 +4312,9 @@ pub enum ChatOrigin { Fork { /// URI of the chat this one was forked from. chat: Uri, - /// Completed source-turn snapshot the fork was taken from. - turn: CompletedChatSourceTurn, + /// Completed source-turn identifier the fork was taken from. + #[serde(rename = "turnId")] + turn_id: String, }, /// Independent side conversation created from a specific turn. #[serde(rename = "sideChat")] diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index 5b9cfce30..d7013dd8f 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -459,18 +459,21 @@ public struct ForkChatSource: Codable, Sendable { public var kind: ChatSourceKind /// URI of the existing source chat. public var chat: String - /// Completed turn in the source chat. Content through this turn is copied into - /// the new chat's visible `turns`. - public var turn: CompletedChatSourceTurn + /// Completed turn identifier in the source chat. + /// + /// Content through this turn is copied into the new chat's visible `turns`. + /// This preserves the existing 0.7.x flat fork wire format (`chat` + + /// `turnId`). + public var turnId: String public init( kind: ChatSourceKind, chat: String, - turn: CompletedChatSourceTurn + turnId: String ) { self.kind = kind self.chat = chat - self.turn = turn + self.turnId = turnId } } @@ -512,9 +515,10 @@ public struct CreateChatParams: Codable, Sendable { /// `kind: "fork"` when the selected agent advertises /// `capabilities.multipleChats.fork`, and /// `kind: "sideChat"` when the selected agent advertises - /// `capabilities.multipleChats.sideChat`. Forks require - /// `source.turn.kind: "completed"`. Side chats accept either a completed turn - /// or the source chat's current active turn. + /// `capabilities.multipleChats.sideChat`. Forks keep the legacy flat + /// `chat` + `turnId` shape and therefore only target completed turns. Side + /// chats use `source.turn.kind` to distinguish a completed source turn from + /// the source chat's current active turn. public var source: ChatSource? /// Initial working-directory subset for this chat. Every entry MUST be /// present in the owning session's `workingDirectories`; the server MUST diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 077d633ac..fc15624a2 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -5277,12 +5277,12 @@ public struct ChatOriginUser: Codable, Sendable { public struct ChatOriginFork: Codable, Sendable { public var kind: ChatOriginKind public var chat: String - public var turn: CompletedChatSourceTurn + public var turnId: String - public init(kind: ChatOriginKind = .fork, chat: String, turn: CompletedChatSourceTurn) { + public init(kind: ChatOriginKind = .fork, chat: String, turnId: String) { self.kind = kind self.chat = chat - self.turn = turn + self.turnId = turnId } } diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 57bf50e19..c2c7251d6 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -76,7 +76,8 @@ Absence or `false` means the corresponding source kind is unsupported. The host MUST reject an unsupported source kind. It MUST also reject a source chat outside the target session, an unknown source chat or turn, or a source that names the chat being created. For `source.kind: "fork"`, the host MUST additionally reject -`source.turn.kind: "active"` — forks only target completed turns. +any source that does not use the legacy flat `chat` + `turnId` fork shape — +forks only target completed turns. Forks and side chats use the source differently: diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 57b7ccfdb..cec7a22e3 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -7305,14 +7305,14 @@ "chat": { "$ref": "#/$defs/URI" }, - "turn": { - "$ref": "#/$defs/CompletedChatSourceTurn" + "turnId": { + "type": "string" } }, "required": [ "kind", "chat", - "turn" + "turnId" ] }, { @@ -7354,7 +7354,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins use `turn.kind` to distinguish completed-turn provenance from\nactive-turn snapshot provenance.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInputQuestion": { "oneOf": [ diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 309312b22..c0ba378da 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -1112,15 +1112,15 @@ "$ref": "#/$defs/URI", "description": "URI of the existing source chat." }, - "turn": { - "$ref": "#/$defs/CompletedChatSourceTurn", - "description": "Completed turn in the source chat. Content through this turn is copied into\nthe new chat's visible `turns`." + "turnId": { + "type": "string", + "description": "Completed turn identifier in the source chat.\n\nContent through this turn is copied into the new chat's visible `turns`.\nThis preserves the existing 0.7.x flat fork wire format (`chat` +\n`turnId`)." } }, "required": [ "kind", "chat", - "turn" + "turnId" ] }, "SideChatSource": { @@ -1164,7 +1164,7 @@ }, "source": { "$ref": "#/$defs/ChatSource", - "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks require\n`source.turn.kind: \"completed\"`. Side chats accept either a completed turn\nor the source chat's current active turn." + "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks keep the legacy flat\n`chat` + `turnId` shape and therefore only target completed turns. Side\nchats use `source.turn.kind` to distinguish a completed source turn from\nthe source chat's current active turn." }, "workingDirectories": { "type": "array", @@ -9028,14 +9028,14 @@ "chat": { "$ref": "#/$defs/URI" }, - "turn": { - "$ref": "#/$defs/CompletedChatSourceTurn" + "turnId": { + "type": "string" } }, "required": [ "kind", "chat", - "turn" + "turnId" ] }, { @@ -9077,7 +9077,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins use `turn.kind` to distinguish completed-turn provenance from\nactive-turn snapshot provenance.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInteractivity": { "enum": [ diff --git a/schema/errors.schema.json b/schema/errors.schema.json index bc2357594..05956e1b4 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -6132,15 +6132,15 @@ "$ref": "#/$defs/URI", "description": "URI of the existing source chat." }, - "turn": { - "$ref": "#/$defs/CompletedChatSourceTurn", - "description": "Completed turn in the source chat. Content through this turn is copied into\nthe new chat's visible `turns`." + "turnId": { + "type": "string", + "description": "Completed turn identifier in the source chat.\n\nContent through this turn is copied into the new chat's visible `turns`.\nThis preserves the existing 0.7.x flat fork wire format (`chat` +\n`turnId`)." } }, "required": [ "kind", "chat", - "turn" + "turnId" ] }, "SideChatSource": { @@ -6184,7 +6184,7 @@ }, "source": { "$ref": "#/$defs/ChatSource", - "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks require\n`source.turn.kind: \"completed\"`. Side chats accept either a completed turn\nor the source chat's current active turn." + "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks keep the legacy flat\n`chat` + `turnId` shape and therefore only target completed turns. Side\nchats use `source.turn.kind` to distinguish a completed source turn from\nthe source chat's current active turn." }, "workingDirectories": { "type": "array", @@ -6615,14 +6615,14 @@ "chat": { "$ref": "#/$defs/URI" }, - "turn": { - "$ref": "#/$defs/CompletedChatSourceTurn" + "turnId": { + "type": "string" } }, "required": [ "kind", "chat", - "turn" + "turnId" ] }, { @@ -6664,7 +6664,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins use `turn.kind` to distinguish completed-turn provenance from\nactive-turn snapshot provenance.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInteractivity": { "enum": [ diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index edce7e0fd..e78c7984e 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -5429,14 +5429,14 @@ "chat": { "$ref": "#/$defs/URI" }, - "turn": { - "$ref": "#/$defs/CompletedChatSourceTurn" + "turnId": { + "type": "string" } }, "required": [ "kind", "chat", - "turn" + "turnId" ] }, { @@ -5478,7 +5478,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins use `turn.kind` to distinguish completed-turn provenance from\nactive-turn snapshot provenance.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInteractivity": { "enum": [ diff --git a/schema/state.schema.json b/schema/state.schema.json index fa46aa23c..9a6013a58 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -5119,14 +5119,14 @@ "chat": { "$ref": "#/$defs/URI" }, - "turn": { - "$ref": "#/$defs/CompletedChatSourceTurn" + "turnId": { + "type": "string" } }, "required": [ "kind", "chat", - "turn" + "turnId" ] }, { @@ -5168,7 +5168,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins use `turn.kind` to distinguish completed-turn provenance from\nactive-turn snapshot provenance.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInputQuestion": { "oneOf": [ diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 331a6b05f..b4f89802b 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -1027,7 +1027,7 @@ func (*ChatUserOrigin) isChatOrigin() {} type ChatForkOrigin struct { \tKind ChatOriginKind \`json:"kind"\` \tChat URI \`json:"chat"\` -\tTurn CompletedChatSourceTurn \`json:"turn"\` +\tTurnId string \`json:"turnId"\` } func (*ChatForkOrigin) isChatOrigin() {} diff --git a/scripts/generate-json-schema.test.ts b/scripts/generate-json-schema.test.ts index 83bf1b252..5c9045654 100644 --- a/scripts/generate-json-schema.test.ts +++ b/scripts/generate-json-schema.test.ts @@ -103,7 +103,7 @@ describe('generated JSON schemas', () => { assert.deepEqual(kinds, ['user', 'fork', 'sideChat', 'tool']); }); - it('references structured source-turn payloads from ChatOrigin', () => { + it('preserves flat fork provenance and structured side-chat turn payloads', () => { const defs = schema.$defs as Record>; const chatOrigin = defs.ChatOrigin; const branches = chatOrigin.oneOf as Array>; @@ -115,13 +115,25 @@ describe('generated JSON schemas', () => { ); assert.deepEqual( - branchByKind.get('fork')?.turn, - { $ref: '#/$defs/CompletedChatSourceTurn' }, + branchByKind.get('fork')?.turnId?.type, + 'string', ); assert.deepEqual( - branchByKind.get('sideChat')?.turn, - { $ref: '#/$defs/ChatSourceTurn' }, + branchByKind.get('sideChat')?.turn?.$ref, + '#/$defs/ChatSourceTurn', ); + + const chatSource = defs.ChatSource; + if (chatSource) { + assert.deepEqual( + defs.ForkChatSource?.properties?.turnId?.type, + 'string', + ); + assert.deepEqual( + defs.SideChatSource?.properties?.turn?.$ref, + '#/$defs/ChatSourceTurn', + ); + } }); }); } diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 065283174..7077c1b3f 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -942,7 +942,7 @@ data class ChatOriginUser( data class ChatOriginFork( val kind: ChatOriginKind = ChatOriginKind.FORK, val chat: String, - val turn: CompletedChatSourceTurn, + val turnId: String, ) @Serializable diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 851373a0f..d437f2d77 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -1140,8 +1140,9 @@ pub enum ChatOrigin { Fork { /// URI of the chat this one was forked from. chat: Uri, - /// Completed source-turn snapshot the fork was taken from. - turn: CompletedChatSourceTurn, + /// Completed source-turn identifier the fork was taken from. + #[serde(rename = "turnId")] + turn_id: String, }, /// Independent side conversation created from a specific turn. #[serde(rename = "sideChat")] diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index f8ad4e6a9..94f80e788 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -978,12 +978,12 @@ function generateChatOriginSwift(): string { public struct ChatOriginFork: Codable, Sendable { public var kind: ChatOriginKind public var chat: String - public var turn: CompletedChatSourceTurn + public var turnId: String - public init(kind: ChatOriginKind = .fork, chat: String, turn: CompletedChatSourceTurn) { + public init(kind: ChatOriginKind = .fork, chat: String, turnId: String) { self.kind = kind self.chat = chat - self.turn = turn + self.turnId = turnId } } diff --git a/types/channels-chat/commands.ts b/types/channels-chat/commands.ts index 823f9b8b3..953a3919c 100644 --- a/types/channels-chat/commands.ts +++ b/types/channels-chat/commands.ts @@ -8,7 +8,6 @@ import type { URI } from '../common/state.js'; import type { BaseParams } from '../common/commands.js'; import type { ChatSourceTurn, - CompletedChatSourceTurn, Message, } from './state.js'; @@ -33,10 +32,13 @@ export interface ForkChatSource { /** URI of the existing source chat. */ chat: URI; /** - * Completed turn in the source chat. Content through this turn is copied into - * the new chat's visible `turns`. + * Completed turn identifier in the source chat. + * + * Content through this turn is copied into the new chat's visible `turns`. + * This preserves the existing 0.7.x flat fork wire format (`chat` + + * `turnId`). */ - turn: CompletedChatSourceTurn; + turnId: string; } /** @@ -90,9 +92,10 @@ export interface CreateChatParams extends BaseParams { * `kind: "fork"` when the selected agent advertises * `capabilities.multipleChats.fork`, and * `kind: "sideChat"` when the selected agent advertises - * `capabilities.multipleChats.sideChat`. Forks require - * `source.turn.kind: "completed"`. Side chats accept either a completed turn - * or the source chat's current active turn. + * `capabilities.multipleChats.sideChat`. Forks keep the legacy flat + * `chat` + `turnId` shape and therefore only target completed turns. Side + * chats use `source.turn.kind` to distinguish a completed source turn from + * the source chat's current active turn. */ source?: ChatSource; /** diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index b91434fed..0eb43b359 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -237,6 +237,10 @@ export type ChatSourceTurn = * How a chat came into existence. Clients MAY use it to render * contextual UI (parent indicators, fork markers, "spawned by tool" badges). * + * Fork origins preserve the existing flat `chat` + `turnId` wire shape. Side + * chat origins use `turn.kind` to distinguish completed-turn provenance from + * active-turn snapshot provenance. + * * The `tool` variant records a tool-spawned worker from the worker's side: its * `chat`/`toolCallId` identify the spawning tool call in the parent chat. This * is the canonical record of the spawn relationship. The same edge is surfaced @@ -247,7 +251,7 @@ export type ChatSourceTurn = */ export type ChatOrigin = | { kind: ChatOriginKind.User } - | { kind: ChatOriginKind.Fork; chat: URI; turn: CompletedChatSourceTurn } + | { kind: ChatOriginKind.Fork; chat: URI; turnId: string } | { kind: ChatOriginKind.SideChat; chat: URI; turn: ChatSourceTurn } | { kind: ChatOriginKind.Tool; chat: URI; toolCallId: string }; diff --git a/types/test-cases/reducers/172-session-chatremoved.json b/types/test-cases/reducers/172-session-chatremoved.json index ffd6e28f4..8ade791c0 100644 --- a/types/test-cases/reducers/172-session-chatremoved.json +++ b/types/test-cases/reducers/172-session-chatremoved.json @@ -26,10 +26,7 @@ "origin": { "kind": "fork", "chat": "ahp-chat:/c1", - "turn": { - "kind": "completed", - "turnId": "t1" - } + "turnId": "t1" } } ], @@ -57,10 +54,7 @@ "origin": { "kind": "fork", "chat": "ahp-chat:/c1", - "turn": { - "kind": "completed", - "turnId": "t1" - } + "turnId": "t1" } } ] diff --git a/types/test-cases/round-trips/036-chat-source-fork-flat.json b/types/test-cases/round-trips/036-chat-source-fork-flat.json new file mode 100644 index 000000000..3048894b0 --- /dev/null +++ b/types/test-cases/round-trips/036-chat-source-fork-flat.json @@ -0,0 +1,18 @@ +{ + "name": "chat-source-fork-flat", + "group": "A", + "description": "ChatSource preserves the 0.7.x flat fork wire format with a top-level turnId.", + "type": "ChatSource", + "input": { + "kind": "fork", + "chat": "ahp-chat:/main", + "turnId": "turn-12" + }, + "acceptableOutputs": [ + { + "kind": "fork", + "chat": "ahp-chat:/main", + "turnId": "turn-12" + } + ] +} diff --git a/types/test-cases/round-trips/037-chat-origin-fork-flat.json b/types/test-cases/round-trips/037-chat-origin-fork-flat.json new file mode 100644 index 000000000..e516dcbf1 --- /dev/null +++ b/types/test-cases/round-trips/037-chat-origin-fork-flat.json @@ -0,0 +1,36 @@ +{ + "name": "chat-origin-fork-flat", + "group": "A", + "description": "A session/chatAdded action preserves flat fork provenance in ChatOrigin state.", + "type": "StateAction", + "input": { + "type": "session/chatAdded", + "summary": { + "resource": "ahp-chat:/fork", + "title": "Forked investigation", + "status": 1, + "modifiedAt": "2026-07-22T21:00:00.000Z", + "origin": { + "kind": "fork", + "chat": "ahp-chat:/main", + "turnId": "turn-12" + } + } + }, + "acceptableOutputs": [ + { + "type": "session/chatAdded", + "summary": { + "resource": "ahp-chat:/fork", + "title": "Forked investigation", + "status": 1, + "modifiedAt": "2026-07-22T21:00:00.000Z", + "origin": { + "kind": "fork", + "chat": "ahp-chat:/main", + "turnId": "turn-12" + } + } + } + ] +} From e1dfd8f81f725091773f215e53f8b5feeb8f47f7 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 22 Jul 2026 16:30:12 -0700 Subject: [PATCH 4/9] chat: accept legacy fork sources Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahptypes/commands.generated.go | 62 ++++++++++--------- clients/go/ahptypes/state.generated.go | 4 +- .../generated/Commands.generated.kt | 43 +++++++------ .../generated/State.generated.kt | 4 +- clients/rust/crates/ahp-types/src/commands.rs | 33 +++++----- clients/rust/crates/ahp-types/src/state.rs | 4 +- .../Generated/Commands.generated.swift | 35 +++++------ .../Generated/State.generated.swift | 4 +- .../20260722-chat-legacy-fork-source.json | 4 ++ docs/guide/state-model.md | 8 +-- docs/specification/chat-channel.md | 19 +++--- schema/actions.schema.json | 2 +- schema/commands.schema.json | 13 ++-- schema/errors.schema.json | 13 ++-- schema/notifications.schema.json | 2 +- schema/state.schema.json | 2 +- scripts/generate-go.ts | 61 ++++++++++++++---- scripts/generate-json-schema.test.ts | 10 ++- scripts/generate-kotlin.ts | 58 ++++++++++++++--- scripts/generate-rust.ts | 53 ++++++++++++---- scripts/generate-swift.ts | 39 +++++++++--- types/channels-chat/commands.ts | 16 ++--- types/channels-root/state.ts | 4 +- .../036-chat-source-fork-flat.json | 2 - 24 files changed, 312 insertions(+), 183 deletions(-) create mode 100644 docs/.changes/20260722-chat-legacy-fork-source.json diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index 6b4c90516..bd03e5025 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -386,8 +386,6 @@ type DisposeSessionParams struct { // Copies source history through a completed turn into the new chat. type ForkChatSource struct { - // Discriminant - Kind ChatSourceKind `json:"kind"` // URI of the existing source chat. Chat URI `json:"chat"` // Completed turn identifier in the source chat. @@ -425,8 +423,8 @@ type CreateChatParams struct { InitialMessage *Message `json:"initialMessage,omitempty"` // Optional source chat and source turn. // - // The source chat MUST belong to this session. Clients MUST only request - // `kind: "fork"` when the selected agent advertises + // The source chat MUST belong to this session. Clients MUST only request the + // flat fork shape (`chat` + `turnId`) when the selected agent advertises // `capabilities.multipleChats.fork`, and // `kind: "sideChat"` when the selected agent advertises // `capabilities.multipleChats.sideChat`. Forks keep the legacy flat @@ -437,8 +435,9 @@ type CreateChatParams struct { // Initial working-directory subset for this chat. Every entry MUST be // present in the owning session's `workingDirectories`; the server MUST // reject any entry that is not. When absent, the chat inherits the full - // session set. Forked chats (`source.kind === "fork"`) inherit the source - // chat's `workingDirectories`; this field is ignored for forks. + // session set. Forked chats (those whose `source` uses the flat `chat` + + // `turnId` shape) inherit the source chat's `workingDirectories`; this field + // is ignored for forks. // // A client MUST NOT supply this field unless the agent advertises // {@link AgentCapabilities.multipleWorkingDirectories}. @@ -450,8 +449,9 @@ type CreateChatParams struct { // {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY // reject creation that omits it, or fall back to the first of the chat's // directories. Fixed at creation and reported (read-only) on - // {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a - // `source.kind === "fork"` chat inherits the source chat's primary). + // {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose + // `source` uses the flat `chat` + `turnId` shape inherits the source chat's + // primary). PrimaryWorkingDirectory *URI `json:"primaryWorkingDirectory,omitempty"` } @@ -1126,44 +1126,46 @@ type ChatSource struct { Value isChatSource } -// isChatSource is the marker interface implemented by every -// concrete variant of ChatSource. +// isChatSource is the marker interface for chat source variants. type isChatSource interface{ isChatSource() } func (*ForkChatSource) isChatSource() {} func (*SideChatSource) isChatSource() {} -// UnmarshalJSON decodes the variant indicated by the "kind" discriminator. -func (u *ChatSource) UnmarshalJSON(data []byte) error { - disc, _, err := readDiscriminator(data, "kind") +// UnmarshalJSON decodes side-chat sources by `kind: "sideChat"`. Any other +// object without a `kind` is treated as the legacy flat fork payload. +func (s *ChatSource) UnmarshalJSON(data []byte) error { + disc, ok, err := readDiscriminator(data, "kind") if err != nil { return err } - switch disc { - case "fork": - var value ForkChatSource - if err := json.Unmarshal(data, &value); err != nil { - return err - } - u.Value = &value - case "sideChat": - var value SideChatSource - if err := json.Unmarshal(data, &value); err != nil { - return err + if ok { + switch disc { + case "sideChat": + var value SideChatSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + s.Value = &value + return nil + default: + return &json.UnmarshalTypeError{Value: "ChatSource kind " + disc, Type: nil} } - u.Value = &value - default: - return &json.UnmarshalTypeError{Value: "ChatSource", Type: nil} } + var value ForkChatSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + s.Value = &value return nil } // MarshalJSON encodes the active variant back to JSON. -func (u ChatSource) MarshalJSON() ([]byte, error) { - if u.Value == nil { +func (s ChatSource) MarshalJSON() ([]byte, error) { + if s.Value == nil { return []byte("null"), nil } - return json.Marshal(u.Value) + return json.Marshal(s.Value) } // ─── ReconnectResult Union ──────────────────────────────────────────── diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 003740765..d20c53051 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -614,8 +614,8 @@ type AgentCapabilities struct { // Options for the {@link AgentCapabilities.multipleChats} capability. type MultipleChatsCapability struct { // The agent can fork a chat from a specific turn. When absent or `false`, - // clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to - // `createChat`. + // clients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`) + // to `createChat`. // Forking always implies multi-chat support. Fork *bool `json:"fork,omitempty"` // The agent can create a side chat from a specific turn. When absent or 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 3c21312aa..0b3601255 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 @@ -468,10 +468,6 @@ data class DisposeSessionParams( @Serializable data class ForkChatSource( - /** - * Discriminant - */ - val kind: ChatSourceKind, /** * URI of the existing source chat. */ @@ -525,8 +521,8 @@ data class CreateChatParams( /** * Optional source chat and source turn. * - * The source chat MUST belong to this session. Clients MUST only request - * `kind: "fork"` when the selected agent advertises + * The source chat MUST belong to this session. Clients MUST only request the + * flat fork shape (`chat` + `turnId`) when the selected agent advertises * `capabilities.multipleChats.fork`, and * `kind: "sideChat"` when the selected agent advertises * `capabilities.multipleChats.sideChat`. Forks keep the legacy flat @@ -539,8 +535,9 @@ data class CreateChatParams( * Initial working-directory subset for this chat. Every entry MUST be * present in the owning session's `workingDirectories`; the server MUST * reject any entry that is not. When absent, the chat inherits the full - * session set. Forked chats (`source.kind === "fork"`) inherit the source - * chat's `workingDirectories`; this field is ignored for forks. + * session set. Forked chats (those whose `source` uses the flat `chat` + + * `turnId` shape) inherit the source chat's `workingDirectories`; this field + * is ignored for forks. * * A client MUST NOT supply this field unless the agent advertises * {@link AgentCapabilities.multipleWorkingDirectories}. @@ -554,8 +551,9 @@ data class CreateChatParams( * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY * reject creation that omits it, or fall back to the first of the chat's * directories. Fixed at creation and reported (read-only) on - * {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a - * `source.kind === "fork"` chat inherits the source chat's primary). + * {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose + * `source` uses the flat `chat` + `turnId` shape inherits the source chat's + * primary). */ val primaryWorkingDirectory: String? = null ) @@ -1299,10 +1297,12 @@ data class ChangesetOperationFollowUp( // ─── ChatSource Union ─────────────────────────────────────────────────────── @Serializable(with = ChatSourceSerializer::class) -sealed interface ChatSource +sealed interface ChatSource { +} @JvmInline value class ChatSourceFork(val value: ForkChatSource) : ChatSource + @JvmInline value class ChatSourceSideChat(val value: SideChatSource) : ChatSource @@ -1316,12 +1316,15 @@ internal object ChatSourceSerializer : KSerializer { val element = input.decodeJsonElement() val obj = element as? JsonObject ?: error("Expected JsonObject for ChatSource") - val discriminant = (obj["kind"] as? JsonPrimitive)?.content - ?: error("Missing kind discriminator on ChatSource") - return when (discriminant) { - "fork" -> ChatSourceFork(input.json.decodeFromJsonElement(ForkChatSource.serializer(), element)) - "sideChat" -> ChatSourceSideChat(input.json.decodeFromJsonElement(SideChatSource.serializer(), element)) - else -> error("Unknown ChatSource discriminator: $discriminant") + val kind = (obj["kind"] as? JsonPrimitive)?.contentOrNull + return when (kind) { + "sideChat" -> ChatSourceSideChat( + input.json.decodeFromJsonElement(SideChatSource.serializer(), element), + ) + null -> ChatSourceFork( + input.json.decodeFromJsonElement(ForkChatSource.serializer(), element), + ) + else -> error("Unknown ChatSource discriminator: $kind") } } @@ -1329,8 +1332,10 @@ internal object ChatSourceSerializer : KSerializer { val output = encoder as? JsonEncoder ?: error("ChatSource can only be serialized to JSON") val element: JsonElement = when (value) { - is ChatSourceFork -> output.json.encodeToJsonElement(ForkChatSource.serializer(), value.value) - is ChatSourceSideChat -> output.json.encodeToJsonElement(SideChatSource.serializer(), value.value) + is ChatSourceFork -> + output.json.encodeToJsonElement(ForkChatSource.serializer(), value.value) + is ChatSourceSideChat -> + output.json.encodeToJsonElement(SideChatSource.serializer(), value.value) } output.encodeJsonElement(element) } 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 e023ec325..116c11beb 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 @@ -999,8 +999,8 @@ data class AgentCapabilities( data class MultipleChatsCapability( /** * The agent can fork a chat from a specific turn. When absent or `false`, - * clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to - * `createChat`. + * clients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`) + * to `createChat`. * Forking always implies multi-chat support. */ val fork: Boolean? = null, diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index b49141ed2..2ab62e6b4 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -483,8 +483,6 @@ pub struct DisposeSessionParams { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ForkChatSource { - /// Discriminant - pub kind: ChatSourceKind, /// URI of the existing source chat. pub chat: Uri, /// Completed turn identifier in the source chat. @@ -527,8 +525,8 @@ pub struct CreateChatParams { pub initial_message: Option, /// Optional source chat and source turn. /// - /// The source chat MUST belong to this session. Clients MUST only request - /// `kind: "fork"` when the selected agent advertises + /// The source chat MUST belong to this session. Clients MUST only request the + /// flat fork shape (`chat` + `turnId`) when the selected agent advertises /// `capabilities.multipleChats.fork`, and /// `kind: "sideChat"` when the selected agent advertises /// `capabilities.multipleChats.sideChat`. Forks keep the legacy flat @@ -540,8 +538,9 @@ pub struct CreateChatParams { /// Initial working-directory subset for this chat. Every entry MUST be /// present in the owning session's `workingDirectories`; the server MUST /// reject any entry that is not. When absent, the chat inherits the full - /// session set. Forked chats (`source.kind === "fork"`) inherit the source - /// chat's `workingDirectories`; this field is ignored for forks. + /// session set. Forked chats (those whose `source` uses the flat `chat` + + /// `turnId` shape) inherit the source chat's `workingDirectories`; this field + /// is ignored for forks. /// /// A client MUST NOT supply this field unless the agent advertises /// {@link AgentCapabilities.multipleWorkingDirectories}. @@ -554,8 +553,9 @@ pub struct CreateChatParams { /// {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY /// reject creation that omits it, or fall back to the first of the chat's /// directories. Fixed at creation and reported (read-only) on - /// {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a - /// `source.kind === "fork"` chat inherits the source chat's primary). + /// {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose + /// `source` uses the flat `chat` + `turnId` shape inherits the source chat's + /// primary). #[serde(default, skip_serializing_if = "Option::is_none")] pub primary_working_directory: Option, } @@ -1352,7 +1352,9 @@ pub struct ChangesetOperationFollowUp { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(try_from = "serde_json::Value", into = "serde_json::Value")] pub enum ChatSource { + /// Copies source history through a completed turn into the new chat. Fork(ForkChatSource), + /// Supplies source context to a new side chat without copying it into the side chat's visible history. SideChat(SideChatSource), } @@ -1363,18 +1365,15 @@ impl TryFrom for ChatSource { let Some(object) = value.as_object() else { return Err("ChatSource must be a JSON object".to_string()); }; - let Some(kind) = object.get("kind").and_then(|value| value.as_str()) else { - return Err("ChatSource is missing a string kind discriminant".to_string()); - }; - match kind { - "fork" => serde_json::from_value(value) - .map(|inner| Self::Fork(inner)) + match object.get("kind").and_then(|field| field.as_str()) { + Some("sideChat") => serde_json::from_value(value) + .map(Self::SideChat) .map_err(|error| error.to_string()), - "sideChat" => serde_json::from_value(value) - .map(|inner| Self::SideChat(inner)) + Some(kind) => Err(format!("unknown kind: {kind}")), + None => serde_json::from_value(value) + .map(Self::Fork) .map_err(|error| error.to_string()), - _ => Err(format!("unknown kind: {kind}")), } } } diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 1998c53c6..cadd39a60 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -839,8 +839,8 @@ pub struct AgentCapabilities { #[serde(rename_all = "camelCase")] pub struct MultipleChatsCapability { /// The agent can fork a chat from a specific turn. When absent or `false`, - /// clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to - /// `createChat`. + /// clients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`) + /// to `createChat`. /// Forking always implies multi-chat support. #[serde(default, skip_serializing_if = "Option::is_none")] pub fork: Option, diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index d7013dd8f..7b9903954 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -455,8 +455,6 @@ public struct DisposeSessionParams: Codable, Sendable { } public struct ForkChatSource: Codable, Sendable { - /// Discriminant - public var kind: ChatSourceKind /// URI of the existing source chat. public var chat: String /// Completed turn identifier in the source chat. @@ -467,11 +465,9 @@ public struct ForkChatSource: Codable, Sendable { public var turnId: String public init( - kind: ChatSourceKind, chat: String, turnId: String ) { - self.kind = kind self.chat = chat self.turnId = turnId } @@ -511,8 +507,8 @@ public struct CreateChatParams: Codable, Sendable { public var initialMessage: Message? /// Optional source chat and source turn. /// - /// The source chat MUST belong to this session. Clients MUST only request - /// `kind: "fork"` when the selected agent advertises + /// The source chat MUST belong to this session. Clients MUST only request the + /// flat fork shape (`chat` + `turnId`) when the selected agent advertises /// `capabilities.multipleChats.fork`, and /// `kind: "sideChat"` when the selected agent advertises /// `capabilities.multipleChats.sideChat`. Forks keep the legacy flat @@ -523,8 +519,9 @@ public struct CreateChatParams: Codable, Sendable { /// Initial working-directory subset for this chat. Every entry MUST be /// present in the owning session's `workingDirectories`; the server MUST /// reject any entry that is not. When absent, the chat inherits the full - /// session set. Forked chats (`source.kind === "fork"`) inherit the source - /// chat's `workingDirectories`; this field is ignored for forks. + /// session set. Forked chats (those whose `source` uses the flat `chat` + + /// `turnId` shape) inherit the source chat's `workingDirectories`; this field + /// is ignored for forks. /// /// A client MUST NOT supply this field unless the agent advertises /// {@link AgentCapabilities.multipleWorkingDirectories}. @@ -536,8 +533,9 @@ public struct CreateChatParams: Codable, Sendable { /// {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY /// reject creation that omits it, or fall back to the first of the chat's /// directories. Fixed at creation and reported (read-only) on - /// {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a - /// `source.kind === "fork"` chat inherits the source chat's primary). + /// {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose + /// `source` uses the flat `chat` + `turnId` shape inherits the source chat's + /// primary). public var primaryWorkingDirectory: String? public init( @@ -1469,20 +1467,17 @@ public enum ChatSource: Codable, Sendable { case fork(ForkChatSource) case sideChat(SideChatSource) - private enum DiscriminantKey: String, CodingKey { - case discriminant = "kind" - } + private enum DiscriminatorCodingKeys: String, CodingKey { case kind } public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: DiscriminantKey.self) - let discriminant = try container.decode(String.self, forKey: .discriminant) - switch discriminant { - case "fork": - self = .fork(try ForkChatSource(from: decoder)) + let container = try decoder.container(keyedBy: DiscriminatorCodingKeys.self) + switch try container.decodeIfPresent(String.self, forKey: .kind) { case "sideChat": self = .sideChat(try SideChatSource(from: decoder)) - default: - throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown ChatSource discriminant: \(discriminant)") + case nil: + self = .fork(try ForkChatSource(from: decoder)) + case .some(let discriminant): + throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown ChatSource discriminant: \(discriminant)") } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index fc15624a2..3f8344c03 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -667,8 +667,8 @@ public struct AgentCapabilities: Codable, Sendable { public struct MultipleChatsCapability: Codable, Sendable { /// The agent can fork a chat from a specific turn. When absent or `false`, - /// clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to - /// `createChat`. + /// clients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`) + /// to `createChat`. /// Forking always implies multi-chat support. public var fork: Bool? /// The agent can create a side chat from a specific turn. When absent or diff --git a/docs/.changes/20260722-chat-legacy-fork-source.json b/docs/.changes/20260722-chat-legacy-fork-source.json new file mode 100644 index 000000000..917f9d071 --- /dev/null +++ b/docs/.changes/20260722-chat-legacy-fork-source.json @@ -0,0 +1,4 @@ +{ + "type": "fixed", + "message": "`createChat.source` once again accepts the legacy flat fork payload without a `kind` property." +} diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index 4b09e4de6..b73ca694b 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -659,10 +659,10 @@ createChat({ }); ``` -Forked chats (`source.kind: "fork"`) inherit the source chat's -`workingDirectories` and primary, so both fields are ignored for forks. Side -chats can still choose their own subset and primary, whether they start from a -completed source turn or an active-turn snapshot. +Forked chats (those whose `source` uses the flat `{ chat, turnId }` shape) +inherit the source chat's `workingDirectories` and primary, so both fields are +ignored for forks. Side chats can still choose their own subset and primary, +whether they start from a completed source turn or an active-turn snapshot. #### Managing the subset after creation diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index c2c7251d6..43d43880a 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -62,22 +62,23 @@ Clients MAY periodically sync their local input state into the draft by dispatch [`createChat`](/reference/chat#createchat) is a JSON-RPC request. Callers identify the owning session via the request's `channel` parameter (`ahp-session:/`) and MAY supply: - an `initialMessage` to start the first turn immediately — carrying its own [`model`](/reference/chat#message) / [`agent`](/reference/chat#message) selection — and -- a `source` of type [`ChatSource`](/reference/chat#chatsource), whose required `kind` selects either a `fork` or `sideChat` from an existing chat at a specific source turn. +- a `source` of type [`ChatSource`](/reference/chat#chatsource), which is + either the legacy flat fork payload `{ chat, turnId }` or a side-chat payload + with `kind: "sideChat"` selecting a specific source turn. The server allocates the chat URI and adds the chat to the session's catalog (`session/chatAdded` on the session channel) before returning. Clients MUST gate source-based creation using the selected [`AgentInfo.capabilities.multipleChats`](/reference/root#multiplechatscapability): -- `fork: true` permits `source.kind: "fork"`. +- `fork: true` permits the legacy flat fork shape `{ chat, turnId }`. - `sideChat: true` permits `source.kind: "sideChat"`. - -Absence or `false` means the corresponding source kind is unsupported. The host -MUST reject an unsupported source kind. It MUST also reject a source chat outside -the target session, an unknown source chat or turn, or a source that names the -chat being created. For `source.kind: "fork"`, the host MUST additionally reject -any source that does not use the legacy flat `chat` + `turnId` fork shape — -forks only target completed turns. +Absence or `false` means the corresponding source form is unsupported. The host +MUST reject an unsupported source. It MUST also reject a source chat outside the +target session, an unknown source chat or turn, or a source that names the chat +being created. For forks, the host MUST additionally reject any source that does +not use the legacy flat `chat` + `turnId` fork shape — forks only target +completed turns. Forks and side chats use the source differently: diff --git a/schema/actions.schema.json b/schema/actions.schema.json index cec7a22e3..59dc36d12 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -2737,7 +2737,7 @@ "properties": { "fork": { "type": "boolean", - "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatSource} with `kind: \"fork\"` to\n`createChat`.\nForking always implies multi-chat support." + "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`)\nto `createChat`.\nForking always implies multi-chat support." }, "sideChat": { "type": "boolean", diff --git a/schema/commands.schema.json b/schema/commands.schema.json index c0ba378da..854659329 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -1104,10 +1104,6 @@ "type": "object", "description": "Copies source history through a completed turn into the new chat.", "properties": { - "kind": { - "const": "fork", - "description": "Discriminant" - }, "chat": { "$ref": "#/$defs/URI", "description": "URI of the existing source chat." @@ -1118,7 +1114,6 @@ } }, "required": [ - "kind", "chat", "turnId" ] @@ -1164,18 +1159,18 @@ }, "source": { "$ref": "#/$defs/ChatSource", - "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks keep the legacy flat\n`chat` + `turnId` shape and therefore only target completed turns. Side\nchats use `source.turn.kind` to distinguish a completed source turn from\nthe source chat's current active turn." + "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request the\nflat fork shape (`chat` + `turnId`) when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks keep the legacy flat\n`chat` + `turnId` shape and therefore only target completed turns. Side\nchats use `source.turn.kind` to distinguish a completed source turn from\nthe source chat's current active turn." }, "workingDirectories": { "type": "array", "items": { "$ref": "#/$defs/URI" }, - "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (`source.kind === \"fork\"`) inherit the source\nchat's `workingDirectories`; this field is ignored for forks.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." + "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (those whose `source` uses the flat `chat` +\n`turnId` shape) inherit the source chat's `workingDirectories`; this field\nis ignored for forks.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." }, "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on. When set, it MUST be one of the chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nomitted). A client SHOULD supply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first of the chat's\ndirectories. Fixed at creation and reported (read-only) on\n{@link ChatState.primaryWorkingDirectory}. Ignored for forks (a\n`source.kind === \"fork\"` chat inherits the source chat's primary)." + "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on. When set, it MUST be one of the chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nomitted). A client SHOULD supply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first of the chat's\ndirectories. Fixed at creation and reported (read-only) on\n{@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose\n`source` uses the flat `chat` + `turnId` shape inherits the source chat's\nprimary)." } }, "required": [ @@ -1908,7 +1903,7 @@ "properties": { "fork": { "type": "boolean", - "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatSource} with `kind: \"fork\"` to\n`createChat`.\nForking always implies multi-chat support." + "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`)\nto `createChat`.\nForking always implies multi-chat support." }, "sideChat": { "type": "boolean", diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 05956e1b4..65031a034 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -640,7 +640,7 @@ "properties": { "fork": { "type": "boolean", - "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatSource} with `kind: \"fork\"` to\n`createChat`.\nForking always implies multi-chat support." + "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`)\nto `createChat`.\nForking always implies multi-chat support." }, "sideChat": { "type": "boolean", @@ -6124,10 +6124,6 @@ "type": "object", "description": "Copies source history through a completed turn into the new chat.", "properties": { - "kind": { - "const": "fork", - "description": "Discriminant" - }, "chat": { "$ref": "#/$defs/URI", "description": "URI of the existing source chat." @@ -6138,7 +6134,6 @@ } }, "required": [ - "kind", "chat", "turnId" ] @@ -6184,18 +6179,18 @@ }, "source": { "$ref": "#/$defs/ChatSource", - "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks keep the legacy flat\n`chat` + `turnId` shape and therefore only target completed turns. Side\nchats use `source.turn.kind` to distinguish a completed source turn from\nthe source chat's current active turn." + "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request the\nflat fork shape (`chat` + `turnId`) when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks keep the legacy flat\n`chat` + `turnId` shape and therefore only target completed turns. Side\nchats use `source.turn.kind` to distinguish a completed source turn from\nthe source chat's current active turn." }, "workingDirectories": { "type": "array", "items": { "$ref": "#/$defs/URI" }, - "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (`source.kind === \"fork\"`) inherit the source\nchat's `workingDirectories`; this field is ignored for forks.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." + "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (those whose `source` uses the flat `chat` +\n`turnId` shape) inherit the source chat's `workingDirectories`; this field\nis ignored for forks.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." }, "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on. When set, it MUST be one of the chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nomitted). A client SHOULD supply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first of the chat's\ndirectories. Fixed at creation and reported (read-only) on\n{@link ChatState.primaryWorkingDirectory}. Ignored for forks (a\n`source.kind === \"fork\"` chat inherits the source chat's primary)." + "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on. When set, it MUST be one of the chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nomitted). A client SHOULD supply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first of the chat's\ndirectories. Fixed at creation and reported (read-only) on\n{@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose\n`source` uses the flat `chat` + `turnId` shape inherits the source chat's\nprimary)." } }, "required": [ diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index e78c7984e..9ade77881 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -803,7 +803,7 @@ "properties": { "fork": { "type": "boolean", - "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatSource} with `kind: \"fork\"` to\n`createChat`.\nForking always implies multi-chat support." + "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`)\nto `createChat`.\nForking always implies multi-chat support." }, "sideChat": { "type": "boolean", diff --git a/schema/state.schema.json b/schema/state.schema.json index 9a6013a58..65cdb95b8 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -551,7 +551,7 @@ "properties": { "fork": { "type": "boolean", - "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatSource} with `kind: \"fork\"` to\n`createChat`.\nForking always implies multi-chat support." + "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`)\nto `createChat`.\nForking always implies multi-chat support." }, "sideChat": { "type": "boolean", diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index b4f89802b..0ef595d70 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -1520,16 +1520,6 @@ const RECONNECT_RESULT_UNION: UnionConfig = { ], }; -const CHAT_SOURCE_UNION: UnionConfig = { - name: 'ChatSource', - discriminantField: 'kind', - doc: 'ChatSource identifies how a new chat uses a source chat.', - variants: [ - { variantName: 'Fork', innerType: 'ForkChatSource', wireValue: 'fork' }, - { variantName: 'SideChat', innerType: 'SideChatSource', wireValue: 'sideChat' }, - ], -}; - const CHAT_SOURCE_TURN_UNION: UnionConfig = { name: 'ChatSourceTurn', discriminantField: 'kind', @@ -1603,6 +1593,55 @@ func (t ChangesetOperationTarget) MarshalJSON() ([]byte, error) { }`; } +function generateChatSourceGo(): string { + return `// ChatSource identifies how a new chat uses a source chat. +type ChatSource struct { + Value isChatSource +} + +// isChatSource is the marker interface for chat source variants. +type isChatSource interface{ isChatSource() } + +func (*ForkChatSource) isChatSource() {} +func (*SideChatSource) isChatSource() {} + +// UnmarshalJSON decodes side-chat sources by \`kind: "sideChat"\`. Any other +// object without a \`kind\` is treated as the legacy flat fork payload. +func (s *ChatSource) UnmarshalJSON(data []byte) error { + disc, ok, err := readDiscriminator(data, "kind") + if err != nil { + return err + } + if ok { + switch disc { + case "sideChat": + var value SideChatSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + s.Value = &value + return nil + default: + return &json.UnmarshalTypeError{Value: "ChatSource kind " + disc, Type: nil} + } + } + var value ForkChatSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + s.Value = &value + return nil +} + +// MarshalJSON encodes the active variant back to JSON. +func (s ChatSource) MarshalJSON() ([]byte, error) { + if s.Value == nil { + return []byte("null"), nil + } + return json.Marshal(s.Value) +}`; +} + function generateCommandsFile(project: Project): string { const lines: string[] = [HEADER_WITH_IMPORTS]; @@ -1634,7 +1673,7 @@ function generateCommandsFile(project: Project): string { } lines.push('// ─── ChatSource Union ─────────────────────────────────────────────────\n'); - lines.push(generateDiscriminatedUnion(CHAT_SOURCE_UNION)); + lines.push(generateChatSourceGo()); lines.push(''); lines.push('// ─── ReconnectResult Union ────────────────────────────────────────────\n'); diff --git a/scripts/generate-json-schema.test.ts b/scripts/generate-json-schema.test.ts index 5c9045654..ddfcdd0ba 100644 --- a/scripts/generate-json-schema.test.ts +++ b/scripts/generate-json-schema.test.ts @@ -103,7 +103,7 @@ describe('generated JSON schemas', () => { assert.deepEqual(kinds, ['user', 'fork', 'sideChat', 'tool']); }); - it('preserves flat fork provenance and structured side-chat turn payloads', () => { + it('preserves flat fork sources and structured side-chat turn payloads', () => { const defs = schema.$defs as Record>; const chatOrigin = defs.ChatOrigin; const branches = chatOrigin.oneOf as Array>; @@ -129,10 +129,18 @@ describe('generated JSON schemas', () => { defs.ForkChatSource?.properties?.turnId?.type, 'string', ); + assert.equal( + defs.ForkChatSource?.properties?.kind, + undefined, + ); assert.deepEqual( defs.SideChatSource?.properties?.turn?.$ref, '#/$defs/ChatSourceTurn', ); + assert.deepEqual( + defs.SideChatSource?.properties?.kind?.const, + 'sideChat', + ); } }); }); diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 7077c1b3f..c75c9eb8e 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -1491,15 +1491,6 @@ const RECONNECT_RESULT_UNION: UnionConfig = { ], }; -const CHAT_SOURCE_UNION: UnionConfig = { - name: 'ChatSource', - discriminantField: 'kind', - variants: [ - { caseName: 'Fork', structName: 'ForkChatSource', discriminantValue: 'fork' }, - { caseName: 'SideChat', structName: 'SideChatSource', discriminantValue: 'sideChat' }, - ], -}; - /** * ChangesetOperationTarget — TS discriminated union over `{ kind: "resource" }` * and `{ kind: "range" }`. The variant structs are inline-only in TS (not @@ -1572,6 +1563,53 @@ internal object ChangesetOperationTargetSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("ChatSource") + + override fun deserialize(decoder: Decoder): ChatSource { + val input = decoder as? JsonDecoder + ?: error("ChatSource can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for ChatSource") + val kind = (obj["kind"] as? JsonPrimitive)?.contentOrNull + return when (kind) { + "sideChat" -> ChatSourceSideChat( + input.json.decodeFromJsonElement(SideChatSource.serializer(), element), + ) + null -> ChatSourceFork( + input.json.decodeFromJsonElement(ForkChatSource.serializer(), element), + ) + else -> error("Unknown ChatSource discriminator: $kind") + } + } + + override fun serialize(encoder: Encoder, value: ChatSource) { + val output = encoder as? JsonEncoder + ?: error("ChatSource can only be serialized to JSON") + val element: JsonElement = when (value) { + is ChatSourceFork -> + output.json.encodeToJsonElement(ForkChatSource.serializer(), value.value) + is ChatSourceSideChat -> + output.json.encodeToJsonElement(SideChatSource.serializer(), value.value) + } + output.encodeJsonElement(element) + } +}`; +} + function generateCommandsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; @@ -1602,7 +1640,7 @@ function generateCommandsFile(project: Project): string { lines.push('// ─── ChatSource Union ───────────────────────────────────────────────────────'); lines.push(''); - lines.push(generateDiscriminatedUnion(CHAT_SOURCE_UNION)); + lines.push(generateChatSourceKotlin()); lines.push(''); lines.push('// ─── ReconnectResult Union ──────────────────────────────────────────────────'); diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index d437f2d77..cc354d7b9 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -1556,16 +1556,6 @@ const RECONNECT_RESULT_UNION: UnionConfig = { ], }; -const CHAT_SOURCE_UNION: UnionConfig = { - name: 'ChatSource', - discriminantField: 'kind', - doc: 'How a new chat uses a source chat.', - variants: [ - { variantName: 'Fork', innerType: 'ForkChatSource', wireValue: 'fork' }, - { variantName: 'SideChat', innerType: 'SideChatSource', wireValue: 'sideChat' }, - ], -}; - function generateCommandsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); @@ -1604,7 +1594,7 @@ function generateCommandsFile(project: Project): string { } lines.push('// ─── ChatSource Union ─────────────────────────────────────────────────\n'); - lines.push(generateValueRoutedDiscriminatedUnion(CHAT_SOURCE_UNION)); + lines.push(generateChatSource()); lines.push(''); lines.push('// ─── ReconnectResult Union ────────────────────────────────────────────\n'); @@ -1618,6 +1608,47 @@ function generateCommandsFile(project: Project): string { return lines.join('\n'); } +function generateChatSource(): string { + return `/// How a new chat uses a source chat. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "serde_json::Value", into = "serde_json::Value")] +pub enum ChatSource { + /// Copies source history through a completed turn into the new chat. + Fork(ForkChatSource), + /// Supplies source context to a new side chat without copying it into the side chat's visible history. + SideChat(SideChatSource), +} + +impl TryFrom for ChatSource { + type Error = String; + + fn try_from(value: serde_json::Value) -> Result { + let Some(object) = value.as_object() else { + return Err("ChatSource must be a JSON object".to_string()); + }; + + match object.get("kind").and_then(|field| field.as_str()) { + Some("sideChat") => serde_json::from_value(value) + .map(Self::SideChat) + .map_err(|error| error.to_string()), + Some(kind) => Err(format!("unknown kind: {kind}")), + None => serde_json::from_value(value) + .map(Self::Fork) + .map_err(|error| error.to_string()), + } + } +} + +impl From for serde_json::Value { + fn from(value: ChatSource) -> Self { + match value { + ChatSource::Fork(inner) => serde_json::to_value(inner).expect("serializing ChatSource::Fork"), + ChatSource::SideChat(inner) => serde_json::to_value(inner).expect("serializing ChatSource::SideChat"), + } + } +}`; +} + function generateSubscribeParamsImplRust(): string { return `impl SubscribeParams { /// Create subscribe params with default delivery behavior. diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 94f80e788..29ade62a3 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -1414,15 +1414,6 @@ const RECONNECT_RESULT_UNION: UnionConfig = { ], }; -const CHAT_SOURCE_UNION: UnionConfig = { - name: 'ChatSource', - discriminantField: 'kind', - variants: [ - { caseName: 'fork', structName: 'ForkChatSource', discriminantValue: 'fork' }, - { caseName: 'sideChat', structName: 'SideChatSource', discriminantValue: 'sideChat' }, - ], -}; - function generateCommandsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; @@ -1451,7 +1442,7 @@ function generateCommandsFile(project: Project): string { } lines.push('// MARK: - Command Unions\n'); - lines.push(generateDiscriminatedUnion(CHAT_SOURCE_UNION)); + lines.push(generateChatSourceSwift()); lines.push(''); lines.push('// MARK: - ReconnectResult Union\n'); @@ -1465,6 +1456,34 @@ function generateCommandsFile(project: Project): string { return lines.join('\n'); } +function generateChatSourceSwift(): string { + return `public enum ChatSource: Codable, Sendable { + case fork(ForkChatSource) + case sideChat(SideChatSource) + + private enum DiscriminatorCodingKeys: String, CodingKey { case kind } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminatorCodingKeys.self) + switch try container.decodeIfPresent(String.self, forKey: .kind) { + case "sideChat": + self = .sideChat(try SideChatSource(from: decoder)) + case nil: + self = .fork(try ForkChatSource(from: decoder)) + case .some(let discriminant): + throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown ChatSource discriminant: \\(discriminant)") + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .fork(let value): try value.encode(to: encoder) + case .sideChat(let value): try value.encode(to: encoder) + } + } +}`; +} + function generateChangesetOperationTargetSwift(): string { return `/// Identifies the file or range a \`ChangesetOperation\` should act on. public enum ChangesetOperationTarget: Codable, Sendable { diff --git a/types/channels-chat/commands.ts b/types/channels-chat/commands.ts index 953a3919c..9c9f5423c 100644 --- a/types/channels-chat/commands.ts +++ b/types/channels-chat/commands.ts @@ -27,8 +27,6 @@ export const enum ChatSourceKind { * Copies source history through a completed turn into the new chat. */ export interface ForkChatSource { - /** Discriminant */ - kind: ChatSourceKind.Fork; /** URI of the existing source chat. */ chat: URI; /** @@ -88,8 +86,8 @@ export interface CreateChatParams extends BaseParams { /** * Optional source chat and source turn. * - * The source chat MUST belong to this session. Clients MUST only request - * `kind: "fork"` when the selected agent advertises + * The source chat MUST belong to this session. Clients MUST only request the + * flat fork shape (`chat` + `turnId`) when the selected agent advertises * `capabilities.multipleChats.fork`, and * `kind: "sideChat"` when the selected agent advertises * `capabilities.multipleChats.sideChat`. Forks keep the legacy flat @@ -102,8 +100,9 @@ export interface CreateChatParams extends BaseParams { * Initial working-directory subset for this chat. Every entry MUST be * present in the owning session's `workingDirectories`; the server MUST * reject any entry that is not. When absent, the chat inherits the full - * session set. Forked chats (`source.kind === "fork"`) inherit the source - * chat's `workingDirectories`; this field is ignored for forks. + * session set. Forked chats (those whose `source` uses the flat `chat` + + * `turnId` shape) inherit the source chat's `workingDirectories`; this field + * is ignored for forks. * * A client MUST NOT supply this field unless the agent advertises * {@link AgentCapabilities.multipleWorkingDirectories}. @@ -117,8 +116,9 @@ export interface CreateChatParams extends BaseParams { * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY * reject creation that omits it, or fall back to the first of the chat's * directories. Fixed at creation and reported (read-only) on - * {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a - * `source.kind === "fork"` chat inherits the source chat's primary). + * {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose + * `source` uses the flat `chat` + `turnId` shape inherits the source chat's + * primary). */ primaryWorkingDirectory?: URI; } diff --git a/types/channels-root/state.ts b/types/channels-root/state.ts index d58b05f7a..a2e73f6b1 100644 --- a/types/channels-root/state.ts +++ b/types/channels-root/state.ts @@ -132,8 +132,8 @@ export interface AgentCapabilities { export interface MultipleChatsCapability { /** * The agent can fork a chat from a specific turn. When absent or `false`, - * clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to - * `createChat`. + * clients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`) + * to `createChat`. * Forking always implies multi-chat support. */ fork?: boolean; diff --git a/types/test-cases/round-trips/036-chat-source-fork-flat.json b/types/test-cases/round-trips/036-chat-source-fork-flat.json index 3048894b0..3ecc4382b 100644 --- a/types/test-cases/round-trips/036-chat-source-fork-flat.json +++ b/types/test-cases/round-trips/036-chat-source-fork-flat.json @@ -4,13 +4,11 @@ "description": "ChatSource preserves the 0.7.x flat fork wire format with a top-level turnId.", "type": "ChatSource", "input": { - "kind": "fork", "chat": "ahp-chat:/main", "turnId": "turn-12" }, "acceptableOutputs": [ { - "kind": "fork", "chat": "ahp-chat:/main", "turnId": "turn-12" } From a97fa6aaacc47ff64db76179b901115fea3858a1 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 23 Jul 2026 08:32:04 -0700 Subject: [PATCH 5/9] chat: use stable side chat turn references Drop the nested active/completed source-turn union for side-chat sources and origins so references remain stable as turns move from active to history. Update the docs, fixtures, schemas, and generated clients to resolve side-chat turnIds against the current active turn or retained history as needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahptypes/commands.generated.go | 21 ++-- clients/go/ahptypes/state.generated.go | 93 ++---------------- .../generated/Commands.generated.kt | 21 ++-- .../generated/State.generated.kt | 91 ++---------------- clients/rust/crates/ahp-types/src/commands.rs | 27 +++--- clients/rust/crates/ahp-types/src/state.rs | 96 ++----------------- .../Generated/Commands.generated.swift | 25 ++--- .../Generated/State.generated.swift | 83 ++-------------- .../20260723-stable-side-chat-turn-refs.json | 5 + docs/guide/state-model.md | 11 ++- docs/proposals/multi-chat.md | 10 +- docs/specification/chat-channel.md | 25 +++-- schema/actions.schema.json | 57 +---------- schema/commands.schema.json | 67 ++----------- schema/errors.schema.json | 67 ++----------- schema/notifications.schema.json | 57 +---------- schema/state.schema.json | 57 +---------- scripts/generate-go.ts | 19 +--- scripts/generate-json-schema.test.ts | 14 ++- scripts/generate-kotlin.ts | 18 +--- scripts/generate-rust.ts | 24 +---- scripts/generate-swift.ts | 22 +---- types/channels-chat/commands.ts | 26 ++--- types/channels-chat/state.ts | 67 +++---------- types/channels-root/state.ts | 9 +- types/index.ts | 4 - .../round-trips/031-side-chat-origin.json | 12 +-- .../033-chat-source-side-chat.json | 12 +-- .../034-side-chat-origin-active.json | 12 +-- .../035-chat-source-side-chat-active.json | 12 +-- 30 files changed, 222 insertions(+), 842 deletions(-) create mode 100644 docs/.changes/20260723-stable-side-chat-turn-refs.json diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index bd03e5025..399648272 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -403,14 +403,15 @@ type SideChatSource struct { Kind ChatSourceKind `json:"kind"` // URI of the existing source chat. Chat URI `json:"chat"` - // Source turn in the source chat. + // Stable source-turn identifier in the source chat. // - // When this is `kind: "completed"`, the side chat is seeded from the source - // chat's retained history through that turn. When this is `kind: "active"`, - // the host snapshots the source chat's retained history plus the active turn's - // current user message and any partial assistant response already available - // when accepting `createChat`. - Turn ChatSourceTurn `json:"turn"` + // Hosts resolve this id against the source chat's current `activeTurn` or its + // retained `turns` when accepting `createChat`. If it names the current + // active turn, the host snapshots the source chat's retained history plus + // that turn's current user message and any partial assistant response already + // available. Once that turn later becomes historical, it is still referenced + // by this same identifier. + TurnId string `json:"turnId"` } // Creates a new chat within a session. @@ -429,8 +430,10 @@ type CreateChatParams struct { // `kind: "sideChat"` when the selected agent advertises // `capabilities.multipleChats.sideChat`. Forks keep the legacy flat // `chat` + `turnId` shape and therefore only target completed turns. Side - // chats use `source.turn.kind` to distinguish a completed source turn from - // the source chat's current active turn. + // chats also carry a stable `turnId`, which the host resolves against the + // source chat's current active turn or retained history. If it resolves to + // the active turn, the host snapshots the currently available partial + // response when accepting `createChat`. Source *ChatSource `json:"source,omitempty"` // Initial working-directory subset for this chat. Every entry MUST be // present in the owning session's `workingDirectories`; the server MUST diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index d20c53051..0307195e8 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -75,17 +75,6 @@ const ( ChatOriginKindTool ChatOriginKind = "tool" ) -// Discriminant for {@link ChatSourceTurn} — which kind of source-turn snapshot -// a fork or side chat was created from. -type ChatSourceTurnKind string - -const ( - // A completed turn retained in the source chat's `turns`. - ChatSourceTurnKindCompleted ChatSourceTurnKind = "completed" - // The source chat's current in-progress `activeTurn`. - ChatSourceTurnKindActive ChatSourceTurnKind = "active" -) - // How a user can interact with a chat. // // - `Full` — user can send messages and watch (default when absent) @@ -623,10 +612,11 @@ type MultipleChatsCapability struct { // `kind: "sideChat"` to `createChat`. // // A side chat receives the source turn as context without copying the source - // transcript into its own visible history. The source may be a completed turn - // or the source chat's current active turn; when active, the host snapshots - // the available partial assistant response at creation time. Side-chat - // support always implies multi-chat support. + // transcript into its own visible history. The source is identified by a + // stable `turnId`, which the host resolves against the source chat's current + // `activeTurn` or retained history. When it names the current active turn, + // the host snapshots the available partial assistant response at creation + // time. Side-chat support always implies multi-chat support. SideChat *bool `json:"sideChat,omitempty"` } @@ -1193,28 +1183,6 @@ type ChatSummary struct { PrimaryWorkingDirectory *URI `json:"primaryWorkingDirectory,omitempty"` } -// A completed turn used as a chat source or recorded in chat origin. -type CompletedChatSourceTurn struct { - // Discriminant - Kind ChatSourceTurnKind `json:"kind"` - // Turn identifier in the source chat. - TurnId string `json:"turnId"` -} - -// The current in-progress turn used as a side-chat source or recorded in chat -// origin. -// -// When a host accepts side-chat creation from an active turn, it snapshots the -// source chat's retained history plus that turn's current user message and any -// partial assistant response already available. Later source-turn deltas do not -// retroactively change the created side chat's starting context. -type ActiveChatSourceTurn struct { - // Discriminant - Kind ChatSourceTurnKind `json:"kind"` - // Active turn identifier in the source chat. - TurnId string `json:"turnId"` -} - // A message queued for future delivery to the agent. // // Steering messages are injected into the current turn mid-flight. @@ -4820,51 +4788,6 @@ func (u SessionInputRequest) MarshalJSON() ([]byte, error) { return json.Marshal(u.Value) } -// ChatSourceTurn identifies whether a source-turn snapshot came from a completed or active turn. -type ChatSourceTurn struct { - Value isChatSourceTurn -} - -// isChatSourceTurn is the marker interface implemented by every -// concrete variant of ChatSourceTurn. -type isChatSourceTurn interface{ isChatSourceTurn() } - -func (*CompletedChatSourceTurn) isChatSourceTurn() {} -func (*ActiveChatSourceTurn) isChatSourceTurn() {} - -// UnmarshalJSON decodes the variant indicated by the "kind" discriminator. -func (u *ChatSourceTurn) UnmarshalJSON(data []byte) error { - disc, _, err := readDiscriminator(data, "kind") - if err != nil { - return err - } - switch disc { - case "completed": - var value CompletedChatSourceTurn - if err := json.Unmarshal(data, &value); err != nil { - return err - } - u.Value = &value - case "active": - var value ActiveChatSourceTurn - if err := json.Unmarshal(data, &value); err != nil { - return err - } - u.Value = &value - default: - return &json.UnmarshalTypeError{Value: "ChatSourceTurn", Type: nil} - } - return nil -} - -// MarshalJSON encodes the active variant back to JSON. -func (u ChatSourceTurn) MarshalJSON() ([]byte, error) { - if u.Value == nil { - return []byte("null"), nil - } - return json.Marshal(u.Value) -} - // ChatOrigin describes how a chat came into existence. type ChatOrigin struct { Value isChatOrigin @@ -4888,9 +4811,9 @@ type ChatForkOrigin struct { func (*ChatForkOrigin) isChatOrigin() {} type ChatSideChatOrigin struct { - Kind ChatOriginKind `json:"kind"` - Chat URI `json:"chat"` - Turn ChatSourceTurn `json:"turn"` + Kind ChatOriginKind `json:"kind"` + Chat URI `json:"chat"` + TurnId string `json:"turnId"` } func (*ChatSideChatOrigin) isChatOrigin() {} 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 0b3601255..2a6242eb8 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 @@ -493,15 +493,16 @@ data class SideChatSource( */ val chat: String, /** - * Source turn in the source chat. + * Stable source-turn identifier in the source chat. * - * When this is `kind: "completed"`, the side chat is seeded from the source - * chat's retained history through that turn. When this is `kind: "active"`, - * the host snapshots the source chat's retained history plus the active turn's - * current user message and any partial assistant response already available - * when accepting `createChat`. + * Hosts resolve this id against the source chat's current `activeTurn` or its + * retained `turns` when accepting `createChat`. If it names the current + * active turn, the host snapshots the source chat's retained history plus + * that turn's current user message and any partial assistant response already + * available. Once that turn later becomes historical, it is still referenced + * by this same identifier. */ - val turn: ChatSourceTurn + val turnId: String ) @Serializable @@ -527,8 +528,10 @@ data class CreateChatParams( * `kind: "sideChat"` when the selected agent advertises * `capabilities.multipleChats.sideChat`. Forks keep the legacy flat * `chat` + `turnId` shape and therefore only target completed turns. Side - * chats use `source.turn.kind` to distinguish a completed source turn from - * the source chat's current active turn. + * chats also carry a stable `turnId`, which the host resolves against the + * source chat's current active turn or retained history. If it resolves to + * the active turn, the host snapshots the currently available partial + * response when accepting `createChat`. */ val source: ChatSource? = null, /** 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 116c11beb..baffa3592 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 @@ -195,24 +195,6 @@ enum class ChatOriginKind { TOOL } -/** - * Discriminant for {@link ChatSourceTurn} — which kind of source-turn snapshot - * a fork or side chat was created from. - */ -@Serializable -enum class ChatSourceTurnKind { - /** - * A completed turn retained in the source chat's `turns`. - */ - @SerialName("completed") - COMPLETED, - /** - * The source chat's current in-progress `activeTurn`. - */ - @SerialName("active") - ACTIVE -} - /** * How a user can interact with a chat. * @@ -1010,10 +992,11 @@ data class MultipleChatsCapability( * `kind: "sideChat"` to `createChat`. * * A side chat receives the source turn as context without copying the source - * transcript into its own visible history. The source may be a completed turn - * or the source chat's current active turn; when active, the host snapshots - * the available partial assistant response at creation time. Side-chat - * support always implies multi-chat support. + * transcript into its own visible history. The source is identified by a + * stable `turnId`, which the host resolves against the source chat's current + * `activeTurn` or retained history. When it names the current active turn, + * the host snapshots the available partial assistant response at creation + * time. Side-chat support always implies multi-chat support. */ val sideChat: Boolean? = null ) @@ -1344,30 +1327,6 @@ data class ChatSummary( val primaryWorkingDirectory: String? = null ) -@Serializable -data class CompletedChatSourceTurn( - /** - * Discriminant - */ - val kind: ChatSourceTurnKind, - /** - * Turn identifier in the source chat. - */ - val turnId: String -) - -@Serializable -data class ActiveChatSourceTurn( - /** - * Discriminant - */ - val kind: ChatSourceTurnKind, - /** - * Active turn identifier in the source chat. - */ - val turnId: String -) - @Serializable data class SessionState( /** @@ -4676,44 +4635,6 @@ data class ResourceChange( // ─── Discriminated Unions ─────────────────────────────────────────────────── -@Serializable(with = ChatSourceTurnSerializer::class) -sealed interface ChatSourceTurn - -@JvmInline -value class ChatSourceTurnCompleted(val value: CompletedChatSourceTurn) : ChatSourceTurn -@JvmInline -value class ChatSourceTurnActive(val value: ActiveChatSourceTurn) : ChatSourceTurn - -internal object ChatSourceTurnSerializer : KSerializer { - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("ChatSourceTurn") - - override fun deserialize(decoder: Decoder): ChatSourceTurn { - val input = decoder as? JsonDecoder - ?: error("ChatSourceTurn can only be deserialized from JSON") - val element = input.decodeJsonElement() - val obj = element as? JsonObject - ?: error("Expected JsonObject for ChatSourceTurn") - val discriminant = (obj["kind"] as? JsonPrimitive)?.content - ?: error("Missing kind discriminator on ChatSourceTurn") - return when (discriminant) { - "completed" -> ChatSourceTurnCompleted(input.json.decodeFromJsonElement(CompletedChatSourceTurn.serializer(), element)) - "active" -> ChatSourceTurnActive(input.json.decodeFromJsonElement(ActiveChatSourceTurn.serializer(), element)) - else -> error("Unknown ChatSourceTurn discriminator: $discriminant") - } - } - - override fun serialize(encoder: Encoder, value: ChatSourceTurn) { - val output = encoder as? JsonEncoder - ?: error("ChatSourceTurn can only be serialized to JSON") - val element: JsonElement = when (value) { - is ChatSourceTurnCompleted -> output.json.encodeToJsonElement(CompletedChatSourceTurn.serializer(), value.value) - is ChatSourceTurnActive -> output.json.encodeToJsonElement(ActiveChatSourceTurn.serializer(), value.value) - } - output.encodeJsonElement(element) - } -} - @Serializable(with = ChatOriginSerializer::class) sealed interface ChatOrigin { @JvmInline value class User(val value: ChatOriginUser) : ChatOrigin @@ -4746,7 +4667,7 @@ data class ChatOriginTool( data class ChatOriginSideChat( val kind: ChatOriginKind = ChatOriginKind.SIDE_CHAT, val chat: String, - val turn: ChatSourceTurn, + val turnId: String, ) internal object ChatOriginSerializer : KSerializer { diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index 2ab62e6b4..4a7cffc69 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -15,9 +15,9 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; use crate::actions::{ActionEnvelope, StateAction}; #[allow(unused_imports)] use crate::state::{ - AgentSelection, ChatSourceTurn, CompletedChatSourceTurn, ContentRef, Message, - MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, - Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn, + AgentSelection, ContentRef, Message, MessageAttachment, ModelSelection, SessionActiveClient, + SessionConfigSchema, SessionSummary, Snapshot, SnapshotState, TelemetryCapabilities, + TerminalClaim, TextRange, Turn, }; // ─── Enums ──────────────────────────────────────────────────────────── @@ -502,14 +502,15 @@ pub struct SideChatSource { pub kind: ChatSourceKind, /// URI of the existing source chat. pub chat: Uri, - /// Source turn in the source chat. + /// Stable source-turn identifier in the source chat. /// - /// When this is `kind: "completed"`, the side chat is seeded from the source - /// chat's retained history through that turn. When this is `kind: "active"`, - /// the host snapshots the source chat's retained history plus the active turn's - /// current user message and any partial assistant response already available - /// when accepting `createChat`. - pub turn: ChatSourceTurn, + /// Hosts resolve this id against the source chat's current `activeTurn` or its + /// retained `turns` when accepting `createChat`. If it names the current + /// active turn, the host snapshots the source chat's retained history plus + /// that turn's current user message and any partial assistant response already + /// available. Once that turn later becomes historical, it is still referenced + /// by this same identifier. + pub turn_id: String, } /// Creates a new chat within a session. @@ -531,8 +532,10 @@ pub struct CreateChatParams { /// `kind: "sideChat"` when the selected agent advertises /// `capabilities.multipleChats.sideChat`. Forks keep the legacy flat /// `chat` + `turnId` shape and therefore only target completed turns. Side - /// chats use `source.turn.kind` to distinguish a completed source turn from - /// the source chat's current active turn. + /// chats also carry a stable `turnId`, which the host resolves against the + /// source chat's current active turn or retained history. If it resolves to + /// the active turn, the host snapshots the currently available partial + /// response when accepting `createChat`. #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, /// Initial working-directory subset for this chat. Every entry MUST be diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index cadd39a60..3f98d5422 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -154,18 +154,6 @@ pub enum ChatOriginKind { Tool, } -/// Discriminant for {@link ChatSourceTurn} — which kind of source-turn snapshot -/// a fork or side chat was created from. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ChatSourceTurnKind { - /// A completed turn retained in the source chat's `turns`. - #[serde(rename = "completed")] - Completed, - /// The source chat's current in-progress `activeTurn`. - #[serde(rename = "active")] - Active, -} - /// How a user can interact with a chat. /// /// - `Full` — user can send messages and watch (default when absent) @@ -849,10 +837,11 @@ pub struct MultipleChatsCapability { /// `kind: "sideChat"` to `createChat`. /// /// A side chat receives the source turn as context without copying the source - /// transcript into its own visible history. The source may be a completed turn - /// or the source chat's current active turn; when active, the host snapshots - /// the available partial assistant response at creation time. Side-chat - /// support always implies multi-chat support. + /// transcript into its own visible history. The source is identified by a + /// stable `turnId`, which the host resolves against the source chat's current + /// `activeTurn` or retained history. When it names the current active turn, + /// the host snapshots the available partial assistant response at creation + /// time. Side-chat support always implies multi-chat support. #[serde(default, skip_serializing_if = "Option::is_none")] pub side_chat: Option, } @@ -1158,32 +1147,6 @@ pub struct ChatSummary { pub primary_working_directory: Option, } -/// A completed turn used as a chat source or recorded in chat origin. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CompletedChatSourceTurn { - /// Discriminant - pub kind: ChatSourceTurnKind, - /// Turn identifier in the source chat. - pub turn_id: String, -} - -/// The current in-progress turn used as a side-chat source or recorded in chat -/// origin. -/// -/// When a host accepts side-chat creation from an active turn, it snapshots the -/// source chat's retained history plus that turn's current user message and any -/// partial assistant response already available. Later source-turn deltas do not -/// retroactively change the created side chat's starting context. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ActiveChatSourceTurn { - /// Discriminant - pub kind: ChatSourceTurnKind, - /// Active turn identifier in the source chat. - pub turn_id: String, -} - /// Full state for a single session, loaded when a client subscribes to the session's URI. /// /// Inlines (denormalizes) every {@link SessionMetadata} field directly onto @@ -4256,50 +4219,6 @@ pub struct ResourceChange { // ─── Discriminated Unions ───────────────────────────────────────────── -/// A source-turn snapshot used when creating or describing a chat. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(try_from = "serde_json::Value", into = "serde_json::Value")] -pub enum ChatSourceTurn { - Completed(CompletedChatSourceTurn), - Active(ActiveChatSourceTurn), -} - -impl TryFrom for ChatSourceTurn { - type Error = String; - - fn try_from(value: serde_json::Value) -> Result { - let Some(object) = value.as_object() else { - return Err("ChatSourceTurn must be a JSON object".to_string()); - }; - let Some(kind) = object.get("kind").and_then(|value| value.as_str()) else { - return Err("ChatSourceTurn is missing a string kind discriminant".to_string()); - }; - - match kind { - "completed" => serde_json::from_value(value) - .map(|inner| Self::Completed(inner)) - .map_err(|error| error.to_string()), - "active" => serde_json::from_value(value) - .map(|inner| Self::Active(inner)) - .map_err(|error| error.to_string()), - _ => Err(format!("unknown kind: {kind}")), - } - } -} - -impl From for serde_json::Value { - fn from(value: ChatSourceTurn) -> Self { - match value { - ChatSourceTurn::Completed(inner) => { - serde_json::to_value(inner).expect("serializing ChatSourceTurn::Completed") - } - ChatSourceTurn::Active(inner) => { - serde_json::to_value(inner).expect("serializing ChatSourceTurn::Active") - } - } - } -} - /// How a chat came into existence. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind")] @@ -4321,8 +4240,9 @@ pub enum ChatOrigin { SideChat { /// URI of the chat that supplied the side-chat context. chat: Uri, - /// Source-turn snapshot through which context was supplied. - turn: ChatSourceTurn, + /// Stable source-turn identifier through which context was supplied. + #[serde(rename = "turnId")] + turn_id: String, }, /// Spawned by a tool call in another chat. #[serde(rename = "tool")] diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index 7b9903954..f6dca5acb 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -478,23 +478,24 @@ public struct SideChatSource: Codable, Sendable { public var kind: ChatSourceKind /// URI of the existing source chat. public var chat: String - /// Source turn in the source chat. + /// Stable source-turn identifier in the source chat. /// - /// When this is `kind: "completed"`, the side chat is seeded from the source - /// chat's retained history through that turn. When this is `kind: "active"`, - /// the host snapshots the source chat's retained history plus the active turn's - /// current user message and any partial assistant response already available - /// when accepting `createChat`. - public var turn: ChatSourceTurn + /// Hosts resolve this id against the source chat's current `activeTurn` or its + /// retained `turns` when accepting `createChat`. If it names the current + /// active turn, the host snapshots the source chat's retained history plus + /// that turn's current user message and any partial assistant response already + /// available. Once that turn later becomes historical, it is still referenced + /// by this same identifier. + public var turnId: String public init( kind: ChatSourceKind, chat: String, - turn: ChatSourceTurn + turnId: String ) { self.kind = kind self.chat = chat - self.turn = turn + self.turnId = turnId } } @@ -513,8 +514,10 @@ public struct CreateChatParams: Codable, Sendable { /// `kind: "sideChat"` when the selected agent advertises /// `capabilities.multipleChats.sideChat`. Forks keep the legacy flat /// `chat` + `turnId` shape and therefore only target completed turns. Side - /// chats use `source.turn.kind` to distinguish a completed source turn from - /// the source chat's current active turn. + /// chats also carry a stable `turnId`, which the host resolves against the + /// source chat's current active turn or retained history. If it resolves to + /// the active turn, the host snapshots the currently available partial + /// response when accepting `createChat`. public var source: ChatSource? /// Initial working-directory subset for this chat. Every entry MUST be /// present in the owning session's `workingDirectories`; the server MUST diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 3f8344c03..35833e234 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -97,15 +97,6 @@ public enum ChatOriginKind: String, Codable, Sendable { case tool = "tool" } -/// Discriminant for {@link ChatSourceTurn} — which kind of source-turn snapshot -/// a fork or side chat was created from. -public enum ChatSourceTurnKind: String, Codable, Sendable { - /// A completed turn retained in the source chat's `turns`. - case completed = "completed" - /// The source chat's current in-progress `activeTurn`. - case active = "active" -} - /// How a user can interact with a chat. /// /// - `Full` — user can send messages and watch (default when absent) @@ -676,10 +667,11 @@ public struct MultipleChatsCapability: Codable, Sendable { /// `kind: "sideChat"` to `createChat`. /// /// A side chat receives the source turn as context without copying the source - /// transcript into its own visible history. The source may be a completed turn - /// or the source chat's current active turn; when active, the host snapshots - /// the available partial assistant response at creation time. Side-chat - /// support always implies multi-chat support. + /// transcript into its own visible history. The source is identified by a + /// stable `turnId`, which the host resolves against the source chat's current + /// `activeTurn` or retained history. When it names the current active turn, + /// the host snapshots the available partial assistant response at creation + /// time. Side-chat support always implies multi-chat support. public var sideChat: Bool? public init( @@ -1089,36 +1081,6 @@ public struct ChatSummary: Codable, Sendable { } } -public struct CompletedChatSourceTurn: Codable, Sendable { - /// Discriminant - public var kind: ChatSourceTurnKind - /// Turn identifier in the source chat. - public var turnId: String - - public init( - kind: ChatSourceTurnKind, - turnId: String - ) { - self.kind = kind - self.turnId = turnId - } -} - -public struct ActiveChatSourceTurn: Codable, Sendable { - /// Discriminant - public var kind: ChatSourceTurnKind - /// Active turn identifier in the source chat. - public var turnId: String - - public init( - kind: ChatSourceTurnKind, - turnId: String - ) { - self.kind = kind - self.turnId = turnId - } -} - public struct SessionState: Codable, Sendable { /// Agent provider ID public var provider: String @@ -5237,35 +5199,6 @@ public struct ResourceChange: Codable, Sendable { // MARK: - Discriminated Unions -public enum ChatSourceTurn: Codable, Sendable { - case completed(CompletedChatSourceTurn) - case active(ActiveChatSourceTurn) - - private enum DiscriminantKey: String, CodingKey { - case discriminant = "kind" - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: DiscriminantKey.self) - let discriminant = try container.decode(String.self, forKey: .discriminant) - switch discriminant { - case "completed": - self = .completed(try CompletedChatSourceTurn(from: decoder)) - case "active": - self = .active(try ActiveChatSourceTurn(from: decoder)) - default: - throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown ChatSourceTurn discriminant: \(discriminant)") - } - } - - public func encode(to encoder: Encoder) throws { - switch self { - case .completed(let value): try value.encode(to: encoder) - case .active(let value): try value.encode(to: encoder) - } - } -} - public struct ChatOriginUser: Codable, Sendable { public var kind: ChatOriginKind @@ -5301,12 +5234,12 @@ public struct ChatOriginTool: Codable, Sendable { public struct ChatOriginSideChat: Codable, Sendable { public var kind: ChatOriginKind public var chat: String - public var turn: ChatSourceTurn + public var turnId: String - public init(kind: ChatOriginKind = .sideChat, chat: String, turn: ChatSourceTurn) { + public init(kind: ChatOriginKind = .sideChat, chat: String, turnId: String) { self.kind = kind self.chat = chat - self.turn = turn + self.turnId = turnId } } diff --git a/docs/.changes/20260723-stable-side-chat-turn-refs.json b/docs/.changes/20260723-stable-side-chat-turn-refs.json new file mode 100644 index 000000000..31bfe6b45 --- /dev/null +++ b/docs/.changes/20260723-stable-side-chat-turn-refs.json @@ -0,0 +1,5 @@ +{ + "type": "changed", + "message": "Side-chat sources and origins now carry stable `turnId` references instead of nested active/completed turn snapshots.", + "targets": ["spec", "rust", "kotlin", "typescript", "swift", "go"] +} diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index b73ca694b..71566b14d 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -162,6 +162,14 @@ ChatState { } ``` +Fork and side-chat creation both reference source turns by stable identifiers. +The legacy fork payload remains flat `{ chat, turnId }`; a side chat uses +`{ kind: 'sideChat', chat, turnId }`. For side chats, hosts resolve `turnId` +against either `activeTurn` or historical `turns` at creation time. If it names +the active turn, the host snapshots the currently available response there, +which preserves `/btw`-style side chats even though that same turn later moves +into `turns` when it completes. + The sections below — turns, response parts, tool calls, pending messages, and input requests — describe the contents of `ChatState`. ## Turns @@ -662,7 +670,8 @@ createChat({ Forked chats (those whose `source` uses the flat `{ chat, turnId }` shape) inherit the source chat's `workingDirectories` and primary, so both fields are ignored for forks. Side chats can still choose their own subset and primary, -whether they start from a completed source turn or an active-turn snapshot. +and they still reference their source with a stable `turnId` whether that turn +was active or historical when the side chat was created. #### Managing the subset after creation diff --git a/docs/proposals/multi-chat.md b/docs/proposals/multi-chat.md index 8fafb7005..cfc547bb8 100644 --- a/docs/proposals/multi-chat.md +++ b/docs/proposals/multi-chat.md @@ -430,8 +430,12 @@ a `copilot` per piece of work; `/resume` reopens one at a time): A new chat is forked from a point in an existing chat, seeded with that history, then diverges on its own. Both chats keep sharing the session's context. -A **side chat** starts from the same kind of `(chat, turn)` reference — either a -completed turn or an active-turn snapshot — but does not copy the parent's turns +A **side chat** starts from a stable `(chat, turnId)` reference. The host +resolves that id against either a completed turn or the parent's current active +turn at creation time, but the wire does not snapshot which lifecycle slot held +it. If the id names the active turn, the host snapshots whatever response is +available at that moment. This preserves `/btw`-style side questions without +copying the parent's turns into its own transcript. It is a focused, independent conversation that can later be pulled back into the main chat as a bounded chat attachment. The distinction keeps the side transcript clean while @@ -440,7 +444,7 @@ making the result durable and reusable: | Mode | Source context | New chat's visible history | Return path | | --- | --- | --- | --- | | Fork | Copied through the source turn | Starts with copied parent turns | Continue either branch | -| Side chat | Supplied through the source turn (completed or active snapshot) | Starts empty | Attach through a completed side-chat turn | +| Side chat | Supplied through the source `turnId` (resolved against historical or active turn at create time) | Starts empty | Attach through a completed side-chat turn | Agents advertise these independently through `multipleChats.fork` and `multipleChats.sideChat`, so clients only offer creation modes the selected diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 43d43880a..743653c70 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -64,7 +64,7 @@ Clients MAY periodically sync their local input state into the draft by dispatch - an `initialMessage` to start the first turn immediately — carrying its own [`model`](/reference/chat#message) / [`agent`](/reference/chat#message) selection — and - a `source` of type [`ChatSource`](/reference/chat#chatsource), which is either the legacy flat fork payload `{ chat, turnId }` or a side-chat payload - with `kind: "sideChat"` selecting a specific source turn. + `{ kind: "sideChat", chat, turnId }` selecting a specific source turn. The server allocates the chat URI and adds the chat to the session's catalog (`session/chatAdded` on the session channel) before returning. @@ -80,18 +80,25 @@ being created. For forks, the host MUST additionally reject any source that does not use the legacy flat `chat` + `turnId` fork shape — forks only target completed turns. +For side chats, `turnId` is a stable identity, not a lifecycle snapshot. Hosts +and clients resolve it against the source chat's current `activeTurn` or its +retained `turns` as needed. This keeps `/btw`-style side chats from the +currently active turn working even though that same turn later moves into +historical `turns` when it completes. + Forks and side chats use the source differently: - A **fork** copies source history through the referenced turn into the new chat's visible `turns`, after which the chats diverge. - A **side chat** starts with its own empty visible history. The host supplies source history through the referenced turn as agent context, but does not copy - that history into the side chat's `turns`. When `source.turn.kind` is - `"active"`, the host snapshots the source chat's retained history plus the - active turn's current user message and whatever assistant response parts are - already available when accepting `createChat`; later source-turn deltas do not - retroactively change the side chat's starting context. An `initialMessage`, - when supplied, becomes the side chat's first visible turn. + that history into the side chat's `turns`. When the referenced `turnId` + resolves to the source chat's current `activeTurn`, the host snapshots the + source chat's retained history plus the active turn's current user message and + whatever assistant response parts are already available when accepting + `createChat`; later source-turn deltas do not retroactively change the side + chat's starting context. An `initialMessage`, when supplied, becomes the side + chat's first visible turn. ### Origin @@ -100,8 +107,8 @@ Each chat advertises how it came into existence via [`ChatOrigin`](/reference/ch | Kind | Meaning | |---|---| | `user` | User created the chat explicitly (e.g. via the host UI). | -| `fork` | Forked from an existing chat at a specific completed turn — payload references the source chat URI and source-turn snapshot. | -| `sideChat` | Created as an independent side conversation using context through a specific source turn — payload references the source chat URI and source-turn snapshot (completed or active). | +| `fork` | Forked from an existing chat at a specific completed turn — payload references the source chat URI and stable source `turnId`. | +| `sideChat` | Created as an independent side conversation using context through a specific source turn — payload references the source chat URI and stable source `turnId`, which may have been active or historical when the chat was created. | | `tool` | Spawned by a tool call running in another chat — payload references the source chat URI and tool call id (e.g. a sub-agent delegation). | Clients MAY use the origin to render contextual UI (parent indicators, fork markers, "spawned by tool" badges), but origin is **not** a hierarchy — every chat is equally addressable. diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 59dc36d12..87d04f9bc 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -2741,7 +2741,7 @@ }, "sideChat": { "type": "boolean", - "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. The source may be a completed turn\nor the source chat's current active turn; when active, the host snapshots\nthe available partial assistant response at creation time. Side-chat\nsupport always implies multi-chat support." + "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. The source is identified by a\nstable `turnId`, which the host resolves against the source chat's current\n`activeTurn` or retained history. When it names the current active turn,\nthe host snapshots the available partial assistant response at creation\ntime. Side-chat support always implies multi-chat support." } } }, @@ -4625,42 +4625,6 @@ "modifiedAt" ] }, - "CompletedChatSourceTurn": { - "type": "object", - "description": "A completed turn used as a chat source or recorded in chat origin.", - "properties": { - "kind": { - "const": "completed", - "description": "Discriminant" - }, - "turnId": { - "type": "string", - "description": "Turn identifier in the source chat." - } - }, - "required": [ - "kind", - "turnId" - ] - }, - "ActiveChatSourceTurn": { - "type": "object", - "description": "The current in-progress turn used as a side-chat source or recorded in chat\norigin.\n\nWhen a host accepts side-chat creation from an active turn, it snapshots the\nsource chat's retained history plus that turn's current user message and any\npartial assistant response already available. Later source-turn deltas do not\nretroactively change the created side chat's starting context.", - "properties": { - "kind": { - "const": "active", - "description": "Discriminant" - }, - "turnId": { - "type": "string", - "description": "Active turn identifier in the source chat." - } - }, - "required": [ - "kind", - "turnId" - ] - }, "PendingMessage": { "type": "object", "description": "A message queued for future delivery to the agent.\n\nSteering messages are injected into the current turn mid-flight.\nQueued messages are automatically started as new turns after the\ncurrent turn naturally finishes.", @@ -7272,17 +7236,6 @@ ], "description": "Discriminated union of all MCP server lifecycle states.\nDiscriminated by `kind` (a {@link McpServerStatus} value)." }, - "ChatSourceTurn": { - "oneOf": [ - { - "$ref": "#/$defs/CompletedChatSourceTurn" - }, - { - "$ref": "#/$defs/ActiveChatSourceTurn" - } - ], - "description": "A source-turn reference used by chat creation and durable chat origin state." - }, "ChatOrigin": { "oneOf": [ { @@ -7324,14 +7277,14 @@ "chat": { "$ref": "#/$defs/URI" }, - "turn": { - "$ref": "#/$defs/ChatSourceTurn" + "turnId": { + "type": "string" } }, "required": [ "kind", "chat", - "turn" + "turnId" ] }, { @@ -7354,7 +7307,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins use `turn.kind` to distinguish completed-turn provenance from\nactive-turn snapshot provenance.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins likewise carry a stable `turnId` identity alongside\n`kind: \"sideChat\"` instead of snapshotting whether that turn was active or\nhistorical at creation time. Consumers resolve the identifier against the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInputQuestion": { "oneOf": [ diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 854659329..48fe7e6ed 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -1130,15 +1130,15 @@ "$ref": "#/$defs/URI", "description": "URI of the existing source chat." }, - "turn": { - "$ref": "#/$defs/ChatSourceTurn", - "description": "Source turn in the source chat.\n\nWhen this is `kind: \"completed\"`, the side chat is seeded from the source\nchat's retained history through that turn. When this is `kind: \"active\"`,\nthe host snapshots the source chat's retained history plus the active turn's\ncurrent user message and any partial assistant response already available\nwhen accepting `createChat`." + "turnId": { + "type": "string", + "description": "Stable source-turn identifier in the source chat.\n\nHosts resolve this id against the source chat's current `activeTurn` or its\nretained `turns` when accepting `createChat`. If it names the current\nactive turn, the host snapshots the source chat's retained history plus\nthat turn's current user message and any partial assistant response already\navailable. Once that turn later becomes historical, it is still referenced\nby this same identifier." } }, "required": [ "kind", "chat", - "turn" + "turnId" ] }, "CreateChatParams": { @@ -1159,7 +1159,7 @@ }, "source": { "$ref": "#/$defs/ChatSource", - "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request the\nflat fork shape (`chat` + `turnId`) when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks keep the legacy flat\n`chat` + `turnId` shape and therefore only target completed turns. Side\nchats use `source.turn.kind` to distinguish a completed source turn from\nthe source chat's current active turn." + "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request the\nflat fork shape (`chat` + `turnId`) when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks keep the legacy flat\n`chat` + `turnId` shape and therefore only target completed turns. Side\nchats also carry a stable `turnId`, which the host resolves against the\nsource chat's current active turn or retained history. If it resolves to\nthe active turn, the host snapshots the currently available partial\nresponse when accepting `createChat`." }, "workingDirectories": { "type": "array", @@ -1907,7 +1907,7 @@ }, "sideChat": { "type": "boolean", - "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. The source may be a completed turn\nor the source chat's current active turn; when active, the host snapshots\nthe available partial assistant response at creation time. Side-chat\nsupport always implies multi-chat support." + "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. The source is identified by a\nstable `turnId`, which the host resolves against the source chat's current\n`activeTurn` or retained history. When it names the current active turn,\nthe host snapshots the available partial assistant response at creation\ntime. Side-chat support always implies multi-chat support." } } }, @@ -3791,42 +3791,6 @@ "modifiedAt" ] }, - "CompletedChatSourceTurn": { - "type": "object", - "description": "A completed turn used as a chat source or recorded in chat origin.", - "properties": { - "kind": { - "const": "completed", - "description": "Discriminant" - }, - "turnId": { - "type": "string", - "description": "Turn identifier in the source chat." - } - }, - "required": [ - "kind", - "turnId" - ] - }, - "ActiveChatSourceTurn": { - "type": "object", - "description": "The current in-progress turn used as a side-chat source or recorded in chat\norigin.\n\nWhen a host accepts side-chat creation from an active turn, it snapshots the\nsource chat's retained history plus that turn's current user message and any\npartial assistant response already available. Later source-turn deltas do not\nretroactively change the created side chat's starting context.", - "properties": { - "kind": { - "const": "active", - "description": "Discriminant" - }, - "turnId": { - "type": "string", - "description": "Active turn identifier in the source chat." - } - }, - "required": [ - "kind", - "turnId" - ] - }, "PendingMessage": { "type": "object", "description": "A message queued for future delivery to the agent.\n\nSteering messages are injected into the current turn mid-flight.\nQueued messages are automatically started as new turns after the\ncurrent turn naturally finishes.", @@ -8689,17 +8653,6 @@ ], "description": "An attachment associated with a {@link Message}." }, - "ChatSourceTurn": { - "oneOf": [ - { - "$ref": "#/$defs/CompletedChatSourceTurn" - }, - { - "$ref": "#/$defs/ActiveChatSourceTurn" - } - ], - "description": "A source-turn reference used by chat creation and durable chat origin state." - }, "ChatSource": { "oneOf": [ { @@ -9042,14 +8995,14 @@ "chat": { "$ref": "#/$defs/URI" }, - "turn": { - "$ref": "#/$defs/ChatSourceTurn" + "turnId": { + "type": "string" } }, "required": [ "kind", "chat", - "turn" + "turnId" ] }, { @@ -9072,7 +9025,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins use `turn.kind` to distinguish completed-turn provenance from\nactive-turn snapshot provenance.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins likewise carry a stable `turnId` identity alongside\n`kind: \"sideChat\"` instead of snapshotting whether that turn was active or\nhistorical at creation time. Consumers resolve the identifier against the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInteractivity": { "enum": [ diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 65031a034..55df5f786 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -644,7 +644,7 @@ }, "sideChat": { "type": "boolean", - "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. The source may be a completed turn\nor the source chat's current active turn; when active, the host snapshots\nthe available partial assistant response at creation time. Side-chat\nsupport always implies multi-chat support." + "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. The source is identified by a\nstable `turnId`, which the host resolves against the source chat's current\n`activeTurn` or retained history. When it names the current active turn,\nthe host snapshots the available partial assistant response at creation\ntime. Side-chat support always implies multi-chat support." } } }, @@ -2528,42 +2528,6 @@ "modifiedAt" ] }, - "CompletedChatSourceTurn": { - "type": "object", - "description": "A completed turn used as a chat source or recorded in chat origin.", - "properties": { - "kind": { - "const": "completed", - "description": "Discriminant" - }, - "turnId": { - "type": "string", - "description": "Turn identifier in the source chat." - } - }, - "required": [ - "kind", - "turnId" - ] - }, - "ActiveChatSourceTurn": { - "type": "object", - "description": "The current in-progress turn used as a side-chat source or recorded in chat\norigin.\n\nWhen a host accepts side-chat creation from an active turn, it snapshots the\nsource chat's retained history plus that turn's current user message and any\npartial assistant response already available. Later source-turn deltas do not\nretroactively change the created side chat's starting context.", - "properties": { - "kind": { - "const": "active", - "description": "Discriminant" - }, - "turnId": { - "type": "string", - "description": "Active turn identifier in the source chat." - } - }, - "required": [ - "kind", - "turnId" - ] - }, "PendingMessage": { "type": "object", "description": "A message queued for future delivery to the agent.\n\nSteering messages are injected into the current turn mid-flight.\nQueued messages are automatically started as new turns after the\ncurrent turn naturally finishes.", @@ -6150,15 +6114,15 @@ "$ref": "#/$defs/URI", "description": "URI of the existing source chat." }, - "turn": { - "$ref": "#/$defs/ChatSourceTurn", - "description": "Source turn in the source chat.\n\nWhen this is `kind: \"completed\"`, the side chat is seeded from the source\nchat's retained history through that turn. When this is `kind: \"active\"`,\nthe host snapshots the source chat's retained history plus the active turn's\ncurrent user message and any partial assistant response already available\nwhen accepting `createChat`." + "turnId": { + "type": "string", + "description": "Stable source-turn identifier in the source chat.\n\nHosts resolve this id against the source chat's current `activeTurn` or its\nretained `turns` when accepting `createChat`. If it names the current\nactive turn, the host snapshots the source chat's retained history plus\nthat turn's current user message and any partial assistant response already\navailable. Once that turn later becomes historical, it is still referenced\nby this same identifier." } }, "required": [ "kind", "chat", - "turn" + "turnId" ] }, "CreateChatParams": { @@ -6179,7 +6143,7 @@ }, "source": { "$ref": "#/$defs/ChatSource", - "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request the\nflat fork shape (`chat` + `turnId`) when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks keep the legacy flat\n`chat` + `turnId` shape and therefore only target completed turns. Side\nchats use `source.turn.kind` to distinguish a completed source turn from\nthe source chat's current active turn." + "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request the\nflat fork shape (`chat` + `turnId`) when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks keep the legacy flat\n`chat` + `turnId` shape and therefore only target completed turns. Side\nchats also carry a stable `turnId`, which the host resolves against the\nsource chat's current active turn or retained history. If it resolves to\nthe active turn, the host snapshots the currently available partial\nresponse when accepting `createChat`." }, "workingDirectories": { "type": "array", @@ -6629,14 +6593,14 @@ "chat": { "$ref": "#/$defs/URI" }, - "turn": { - "$ref": "#/$defs/ChatSourceTurn" + "turnId": { + "type": "string" } }, "required": [ "kind", "chat", - "turn" + "turnId" ] }, { @@ -6659,7 +6623,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins use `turn.kind` to distinguish completed-turn provenance from\nactive-turn snapshot provenance.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins likewise carry a stable `turnId` identity alongside\n`kind: \"sideChat\"` instead of snapshotting whether that turn was active or\nhistorical at creation time. Consumers resolve the identifier against the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInteractivity": { "enum": [ @@ -7268,17 +7232,6 @@ "type": "string", "description": "The kind of completion items being requested." }, - "ChatSourceTurn": { - "oneOf": [ - { - "$ref": "#/$defs/CompletedChatSourceTurn" - }, - { - "$ref": "#/$defs/ActiveChatSourceTurn" - } - ], - "description": "A source-turn reference used by chat creation and durable chat origin state." - }, "ChatSource": { "oneOf": [ { diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 9ade77881..0eec127d5 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -807,7 +807,7 @@ }, "sideChat": { "type": "boolean", - "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. The source may be a completed turn\nor the source chat's current active turn; when active, the host snapshots\nthe available partial assistant response at creation time. Side-chat\nsupport always implies multi-chat support." + "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. The source is identified by a\nstable `turnId`, which the host resolves against the source chat's current\n`activeTurn` or retained history. When it names the current active turn,\nthe host snapshots the available partial assistant response at creation\ntime. Side-chat support always implies multi-chat support." } } }, @@ -2691,42 +2691,6 @@ "modifiedAt" ] }, - "CompletedChatSourceTurn": { - "type": "object", - "description": "A completed turn used as a chat source or recorded in chat origin.", - "properties": { - "kind": { - "const": "completed", - "description": "Discriminant" - }, - "turnId": { - "type": "string", - "description": "Turn identifier in the source chat." - } - }, - "required": [ - "kind", - "turnId" - ] - }, - "ActiveChatSourceTurn": { - "type": "object", - "description": "The current in-progress turn used as a side-chat source or recorded in chat\norigin.\n\nWhen a host accepts side-chat creation from an active turn, it snapshots the\nsource chat's retained history plus that turn's current user message and any\npartial assistant response already available. Later source-turn deltas do not\nretroactively change the created side chat's starting context.", - "properties": { - "kind": { - "const": "active", - "description": "Discriminant" - }, - "turnId": { - "type": "string", - "description": "Active turn identifier in the source chat." - } - }, - "required": [ - "kind", - "turnId" - ] - }, "PendingMessage": { "type": "object", "description": "A message queued for future delivery to the agent.\n\nSteering messages are injected into the current turn mid-flight.\nQueued messages are automatically started as new turns after the\ncurrent turn naturally finishes.", @@ -5448,14 +5412,14 @@ "chat": { "$ref": "#/$defs/URI" }, - "turn": { - "$ref": "#/$defs/ChatSourceTurn" + "turnId": { + "type": "string" } }, "required": [ "kind", "chat", - "turn" + "turnId" ] }, { @@ -5478,7 +5442,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins use `turn.kind` to distinguish completed-turn provenance from\nactive-turn snapshot provenance.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins likewise carry a stable `turnId` identity alongside\n`kind: \"sideChat\"` instead of snapshotting whether that turn was active or\nhistorical at creation time. Consumers resolve the identifier against the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInteractivity": { "enum": [ @@ -5765,17 +5729,6 @@ ], "type": "string", "description": "Discriminant for {@link ResourceChange.type}." - }, - "ChatSourceTurn": { - "oneOf": [ - { - "$ref": "#/$defs/CompletedChatSourceTurn" - }, - { - "$ref": "#/$defs/ActiveChatSourceTurn" - } - ], - "description": "A source-turn reference used by chat creation and durable chat origin state." } } } diff --git a/schema/state.schema.json b/schema/state.schema.json index 65cdb95b8..3e22a4065 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -555,7 +555,7 @@ }, "sideChat": { "type": "boolean", - "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. The source may be a completed turn\nor the source chat's current active turn; when active, the host snapshots\nthe available partial assistant response at creation time. Side-chat\nsupport always implies multi-chat support." + "description": "The agent can create a side chat from a specific turn. When absent or\n`false`, clients MUST NOT pass a {@link ChatSource} with\n`kind: \"sideChat\"` to `createChat`.\n\nA side chat receives the source turn as context without copying the source\ntranscript into its own visible history. The source is identified by a\nstable `turnId`, which the host resolves against the source chat's current\n`activeTurn` or retained history. When it names the current active turn,\nthe host snapshots the available partial assistant response at creation\ntime. Side-chat support always implies multi-chat support." } } }, @@ -2439,42 +2439,6 @@ "modifiedAt" ] }, - "CompletedChatSourceTurn": { - "type": "object", - "description": "A completed turn used as a chat source or recorded in chat origin.", - "properties": { - "kind": { - "const": "completed", - "description": "Discriminant" - }, - "turnId": { - "type": "string", - "description": "Turn identifier in the source chat." - } - }, - "required": [ - "kind", - "turnId" - ] - }, - "ActiveChatSourceTurn": { - "type": "object", - "description": "The current in-progress turn used as a side-chat source or recorded in chat\norigin.\n\nWhen a host accepts side-chat creation from an active turn, it snapshots the\nsource chat's retained history plus that turn's current user message and any\npartial assistant response already available. Later source-turn deltas do not\nretroactively change the created side chat's starting context.", - "properties": { - "kind": { - "const": "active", - "description": "Discriminant" - }, - "turnId": { - "type": "string", - "description": "Active turn identifier in the source chat." - } - }, - "required": [ - "kind", - "turnId" - ] - }, "PendingMessage": { "type": "object", "description": "A message queued for future delivery to the agent.\n\nSteering messages are injected into the current turn mid-flight.\nQueued messages are automatically started as new turns after the\ncurrent turn naturally finishes.", @@ -5086,17 +5050,6 @@ ], "description": "Discriminated union of all MCP server lifecycle states.\nDiscriminated by `kind` (a {@link McpServerStatus} value)." }, - "ChatSourceTurn": { - "oneOf": [ - { - "$ref": "#/$defs/CompletedChatSourceTurn" - }, - { - "$ref": "#/$defs/ActiveChatSourceTurn" - } - ], - "description": "A source-turn reference used by chat creation and durable chat origin state." - }, "ChatOrigin": { "oneOf": [ { @@ -5138,14 +5091,14 @@ "chat": { "$ref": "#/$defs/URI" }, - "turn": { - "$ref": "#/$defs/ChatSourceTurn" + "turnId": { + "type": "string" } }, "required": [ "kind", "chat", - "turn" + "turnId" ] }, { @@ -5168,7 +5121,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins use `turn.kind` to distinguish completed-turn provenance from\nactive-turn snapshot provenance.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins likewise carry a stable `turnId` identity alongside\n`kind: \"sideChat\"` instead of snapshotting whether that turn was active or\nhistorical at creation time. Consumers resolve the identifier against the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInputQuestion": { "oneOf": [ diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 0ef595d70..7b19e3096 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -641,7 +641,7 @@ function generateDiscriminatedUnion(cfg: UnionConfig): string { const STATE_ENUMS = [ 'PolicyState', 'SessionLifecycle', 'SessionStatus', - 'ChatOriginKind', 'ChatSourceTurnKind', 'ChatInteractivity', 'PendingMessageKind', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', + 'ChatOriginKind', 'ChatInteractivity', 'PendingMessageKind', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', 'ToolCallConfirmationReason', 'ToolCallRiskAssessmentKind', @@ -677,8 +677,6 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'ChangesSummary' }, { name: 'ChatState' }, { name: 'ChatSummary' }, - { name: 'CompletedChatSourceTurn' }, - { name: 'ActiveChatSourceTurn' }, { name: 'PendingMessage' }, { name: 'ProjectInfo' }, { name: 'SessionConfigPropertySchema' }, @@ -1035,7 +1033,7 @@ func (*ChatForkOrigin) isChatOrigin() {} type ChatSideChatOrigin struct { \tKind ChatOriginKind \`json:"kind"\` \tChat URI \`json:"chat"\` -\tTurn ChatSourceTurn \`json:"turn"\` +\tTurnId string \`json:"turnId"\` } func (*ChatSideChatOrigin) isChatOrigin() {} @@ -1271,8 +1269,6 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(SESSION_INPUT_REQUEST_UNION)); lines.push(''); - lines.push(generateDiscriminatedUnion(CHAT_SOURCE_TURN_UNION)); - lines.push(''); lines.push(generateChatOriginGo()); lines.push(''); lines.push(generateSnapshotState()); @@ -1520,16 +1516,6 @@ const RECONNECT_RESULT_UNION: UnionConfig = { ], }; -const CHAT_SOURCE_TURN_UNION: UnionConfig = { - name: 'ChatSourceTurn', - discriminantField: 'kind', - doc: 'ChatSourceTurn identifies whether a source-turn snapshot came from a completed or active turn.', - variants: [ - { variantName: 'Completed', innerType: 'CompletedChatSourceTurn', wireValue: 'completed' }, - { variantName: 'Active', innerType: 'ActiveChatSourceTurn', wireValue: 'active' }, - ], -}; - function generateChangesetOperationTargetGo(): string { return `// ChangesetOperationTarget identifies the file or range a // ChangesetOperation should act on. @@ -2031,7 +2017,6 @@ function checkExhaustiveness(project: Project): void { 'PingParams', 'TerminalClaim', 'TerminalContentPart', - 'ChatSourceTurn', 'ChatOrigin', 'ChatSource', 'ChatInputQuestion', diff --git a/scripts/generate-json-schema.test.ts b/scripts/generate-json-schema.test.ts index ddfcdd0ba..327e9fb1f 100644 --- a/scripts/generate-json-schema.test.ts +++ b/scripts/generate-json-schema.test.ts @@ -103,7 +103,7 @@ describe('generated JSON schemas', () => { assert.deepEqual(kinds, ['user', 'fork', 'sideChat', 'tool']); }); - it('preserves flat fork sources and structured side-chat turn payloads', () => { + it('preserves flat fork sources and stable side-chat turn identifiers', () => { const defs = schema.$defs as Record>; const chatOrigin = defs.ChatOrigin; const branches = chatOrigin.oneOf as Array>; @@ -119,8 +119,8 @@ describe('generated JSON schemas', () => { 'string', ); assert.deepEqual( - branchByKind.get('sideChat')?.turn?.$ref, - '#/$defs/ChatSourceTurn', + branchByKind.get('sideChat')?.turnId?.type, + 'string', ); const chatSource = defs.ChatSource; @@ -134,13 +134,17 @@ describe('generated JSON schemas', () => { undefined, ); assert.deepEqual( - defs.SideChatSource?.properties?.turn?.$ref, - '#/$defs/ChatSourceTurn', + defs.SideChatSource?.properties?.turnId?.type, + 'string', ); assert.deepEqual( defs.SideChatSource?.properties?.kind?.const, 'sideChat', ); + assert.equal( + defs.SideChatSource?.properties?.turn, + undefined, + ); } }); }); diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index c75c9eb8e..b3f664995 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -788,7 +788,7 @@ internal object ToolResultContentSerializer : KSerializer { const STATE_ENUMS = [ 'PolicyState', 'PendingMessageKind', 'SessionLifecycle', 'SessionStatus', - 'ChatOriginKind', 'ChatSourceTurnKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', + 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', 'ToolCallConfirmationReason', 'ToolCallRiskAssessmentKind', @@ -806,7 +806,7 @@ const STATE_STRUCTS = [ 'MultipleChatsCapability', 'MultipleWorkingDirectoriesCapability', 'SessionModelInfo', 'ModelSelection', 'AgentSelection', 'ConfigPropertySchema', 'ConfigSchema', - 'PendingMessage', 'ChatState', 'ChatSummary', 'CompletedChatSourceTurn', 'ActiveChatSourceTurn', 'SessionState', 'SessionActiveClient', + 'PendingMessage', 'ChatState', 'ChatSummary', 'SessionState', 'SessionActiveClient', 'SessionChatInputRequest', 'SessionToolConfirmationRequest', 'SessionToolClientExecutionRequest', 'SessionToolAuthenticationRequest', 'SessionSummary', 'ChangesSummary', 'ProjectInfo', 'SessionConfigState', 'Turn', 'ActiveTurn', 'Message', @@ -869,15 +869,6 @@ const RESPONSE_PART_UNION: UnionConfig = { unknown: true, }; -const CHAT_SOURCE_TURN_UNION: UnionConfig = { - name: 'ChatSourceTurn', - discriminantField: 'kind', - variants: [ - { caseName: 'Completed', structName: 'CompletedChatSourceTurn', discriminantValue: 'completed' }, - { caseName: 'Active', structName: 'ActiveChatSourceTurn', discriminantValue: 'active' }, - ], -}; - const TOOL_CALL_STATE_UNION: UnionConfig = { name: 'ToolCallState', discriminantField: 'status', @@ -956,7 +947,7 @@ data class ChatOriginTool( data class ChatOriginSideChat( val kind: ChatOriginKind = ChatOriginKind.SIDE_CHAT, val chat: String, - val turn: ChatSourceTurn, + val turnId: String, ) internal object ChatOriginSerializer : KSerializer { @@ -1161,8 +1152,6 @@ function generateStateFile(project: Project): string { lines.push('// ─── Discriminated Unions ───────────────────────────────────────────────────'); lines.push(''); - lines.push(generateDiscriminatedUnion(CHAT_SOURCE_TURN_UNION)); - lines.push(''); lines.push(generateChatOriginKotlin()); lines.push(''); lines.push(generateDiscriminatedUnion(RESPONSE_PART_UNION)); @@ -2011,7 +2000,6 @@ function checkExhaustiveness(project: Project): void { 'ChatInputQuestion', // CHAT_INPUT_QUESTION_UNION discriminated union 'ChatInputAnswerValue', // CHAT_INPUT_ANSWER_VALUE_UNION discriminated union 'ChatInputAnswer', // CHAT_INPUT_ANSWER_UNION discriminated union - 'ChatSourceTurn', // CHAT_SOURCE_TURN_UNION discriminated union 'ChatOrigin', // hand-generated union for inline variants 'ChatSource', // CHAT_SOURCE_UNION discriminated union 'ChatToolCallApprovedAction', // merged into ChatToolCallConfirmedAction diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index cc354d7b9..d255cea00 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -733,7 +733,7 @@ function generateStructFromInterface( const STATE_ENUMS = [ 'PolicyState', 'PendingMessageKind', 'SessionLifecycle', 'SessionStatus', - 'ChatOriginKind', 'ChatSourceTurnKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', + 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', 'ToolCallConfirmationReason', 'ToolCallRiskAssessmentKind', @@ -783,8 +783,6 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'PendingMessage' }, { name: 'ChatState' }, { name: 'ChatSummary' }, - { name: 'CompletedChatSourceTurn' }, - { name: 'ActiveChatSourceTurn' }, { name: 'SessionState' }, { name: 'SessionActiveClient' }, { name: 'SessionChatInputRequest', omitDiscriminants: true }, @@ -1149,8 +1147,9 @@ pub enum ChatOrigin { SideChat { /// URI of the chat that supplied the side-chat context. chat: Uri, - /// Source-turn snapshot through which context was supplied. - turn: ChatSourceTurn, + /// Stable source-turn identifier through which context was supplied. + #[serde(rename = "turnId")] + turn_id: String, }, /// Spawned by a tool call in another chat. #[serde(rename = "tool")] @@ -1168,16 +1167,6 @@ pub enum ChatOrigin { }`; } -const CHAT_SOURCE_TURN_UNION: UnionConfig = { - name: 'ChatSourceTurn', - discriminantField: 'kind', - doc: 'A source-turn snapshot used when creating or describing a chat.', - variants: [ - { variantName: 'Completed', innerType: 'CompletedChatSourceTurn', wireValue: 'completed' }, - { variantName: 'Active', innerType: 'ActiveChatSourceTurn', wireValue: 'active' }, - ], -}; - function generateSnapshotState(): string { return `/// The state payload of a snapshot — root, session, chat, terminal, /// changeset, resource-watch, or annotations state. @@ -1233,8 +1222,6 @@ function generateStateFile(project: Project): string { } lines.push('// ─── Discriminated Unions ─────────────────────────────────────────────\n'); - lines.push(generateValueRoutedDiscriminatedUnion(CHAT_SOURCE_TURN_UNION)); - lines.push(''); lines.push(generateChatOrigin()); lines.push(''); lines.push(generateDiscriminatedUnion(RESPONSE_PART_UNION)); @@ -1561,7 +1548,7 @@ function generateCommandsFile(project: Project): string { lines.push('#[allow(unused_imports)]'); lines.push('use crate::actions::{ActionEnvelope, StateAction};'); lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentSelection, ChatSourceTurn, CompletedChatSourceTurn, ContentRef, Message, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn};'); + lines.push('use crate::state::{AgentSelection, ContentRef, Message, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn};'); lines.push(''); lines.push('// ─── Enums ────────────────────────────────────────────────────────────\n'); @@ -2004,7 +1991,6 @@ function checkExhaustiveness(project: Project): void { 'ChatToolCallDeniedAction', // merged into ChatToolCallConfirmedAction 'ChatToolCallConfirmedAction', // emitted as merged variant 'ChatAction', // source-only union covered by StateAction - 'ChatSourceTurn', 'ChatOrigin', // hand-generated union for inline variants 'ChatSource', 'PingParams', diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 29ade62a3..bc6d08d62 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -538,7 +538,7 @@ function generatePartialStructFromInterface( const STATE_ENUMS = [ 'PolicyState', 'PendingMessageKind', 'SessionLifecycle', 'SessionStatus', - 'ChatOriginKind', 'ChatSourceTurnKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', + 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', 'ToolCallConfirmationReason', 'ToolCallRiskAssessmentKind', @@ -556,7 +556,7 @@ const STATE_STRUCTS = [ 'MultipleChatsCapability', 'MultipleWorkingDirectoriesCapability', 'SessionModelInfo', 'ModelSelection', 'AgentSelection', 'ConfigPropertySchema', 'ConfigSchema', - 'PendingMessage', 'ChatState', 'ChatSummary', 'CompletedChatSourceTurn', 'ActiveChatSourceTurn', 'SessionState', 'SessionActiveClient', + 'PendingMessage', 'ChatState', 'ChatSummary', 'SessionState', 'SessionActiveClient', 'SessionChatInputRequest', 'SessionToolConfirmationRequest', 'SessionToolClientExecutionRequest', 'SessionToolAuthenticationRequest', 'SessionSummary', 'ChangesSummary', 'ProjectInfo', 'SessionConfigState', 'Turn', 'ActiveTurn', 'Message', @@ -623,15 +623,6 @@ const RESPONSE_PART_UNION: UnionConfig = { ], }; -const CHAT_SOURCE_TURN_UNION: UnionConfig = { - name: 'ChatSourceTurn', - discriminantField: 'kind', - variants: [ - { caseName: 'completed', structName: 'CompletedChatSourceTurn', discriminantValue: 'completed' }, - { caseName: 'active', structName: 'ActiveChatSourceTurn', discriminantValue: 'active' }, - ], -}; - const TOOL_CALL_STATE_UNION: UnionConfig = { name: 'ToolCallState', discriminantField: 'status', @@ -1002,12 +993,12 @@ public struct ChatOriginTool: Codable, Sendable { public struct ChatOriginSideChat: Codable, Sendable { public var kind: ChatOriginKind public var chat: String - public var turn: ChatSourceTurn + public var turnId: String - public init(kind: ChatOriginKind = .sideChat, chat: String, turn: ChatSourceTurn) { + public init(kind: ChatOriginKind = .sideChat, chat: String, turnId: String) { self.kind = kind self.chat = chat - self.turn = turn + self.turnId = turnId } } @@ -1074,8 +1065,6 @@ function generateStateFile(project: Project): string { } lines.push('// MARK: - Discriminated Unions\n'); - lines.push(generateDiscriminatedUnion(CHAT_SOURCE_TURN_UNION)); - lines.push(''); lines.push(generateChatOriginSwift()); lines.push(''); lines.push(generateDiscriminatedUnion(RESPONSE_PART_UNION)); @@ -2033,7 +2022,6 @@ function checkExhaustiveness(project: Project): void { 'ChatInputQuestion', // SESSION_INPUT_QUESTION_UNION discriminated union 'ChatInputAnswerValue', // SESSION_INPUT_ANSWER_VALUE_UNION discriminated union 'ChatInputAnswer', // CHAT_INPUT_ANSWER_UNION discriminated union - 'ChatSourceTurn', // CHAT_SOURCE_TURN_UNION discriminated union 'ChatOrigin', // hand-generated union for inline variants 'ChatSource', // CHAT_SOURCE_UNION discriminated union 'ChatToolCallApprovedAction', diff --git a/types/channels-chat/commands.ts b/types/channels-chat/commands.ts index 9c9f5423c..3208482dc 100644 --- a/types/channels-chat/commands.ts +++ b/types/channels-chat/commands.ts @@ -6,10 +6,7 @@ import type { URI } from '../common/state.js'; import type { BaseParams } from '../common/commands.js'; -import type { - ChatSourceTurn, - Message, -} from './state.js'; +import type { Message } from './state.js'; // ─── createChat ────────────────────────────────────────────────────────────── @@ -49,15 +46,16 @@ export interface SideChatSource { /** URI of the existing source chat. */ chat: URI; /** - * Source turn in the source chat. + * Stable source-turn identifier in the source chat. * - * When this is `kind: "completed"`, the side chat is seeded from the source - * chat's retained history through that turn. When this is `kind: "active"`, - * the host snapshots the source chat's retained history plus the active turn's - * current user message and any partial assistant response already available - * when accepting `createChat`. + * Hosts resolve this id against the source chat's current `activeTurn` or its + * retained `turns` when accepting `createChat`. If it names the current + * active turn, the host snapshots the source chat's retained history plus + * that turn's current user message and any partial assistant response already + * available. Once that turn later becomes historical, it is still referenced + * by this same identifier. */ - turn: ChatSourceTurn; + turnId: string; } /** @@ -92,8 +90,10 @@ export interface CreateChatParams extends BaseParams { * `kind: "sideChat"` when the selected agent advertises * `capabilities.multipleChats.sideChat`. Forks keep the legacy flat * `chat` + `turnId` shape and therefore only target completed turns. Side - * chats use `source.turn.kind` to distinguish a completed source turn from - * the source chat's current active turn. + * chats also carry a stable `turnId`, which the host resolves against the + * source chat's current active turn or retained history. If it resolves to + * the active turn, the host snapshots the currently available partial + * response when accepting `createChat`. */ source?: ChatSource; /** diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index 0eb43b359..91c427d8a 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -181,65 +181,22 @@ export const enum ChatOriginKind { Tool = 'tool', } -/** - * Discriminant for {@link ChatSourceTurn} — which kind of source-turn snapshot - * a fork or side chat was created from. - * - * @category Chat State - */ -export const enum ChatSourceTurnKind { - /** A completed turn retained in the source chat's `turns`. */ - Completed = 'completed', - /** The source chat's current in-progress `activeTurn`. */ - Active = 'active', -} - -/** - * A completed turn used as a chat source or recorded in chat origin. - * - * @category Chat State - */ -export interface CompletedChatSourceTurn { - /** Discriminant */ - kind: ChatSourceTurnKind.Completed; - /** Turn identifier in the source chat. */ - turnId: string; -} - -/** - * The current in-progress turn used as a side-chat source or recorded in chat - * origin. - * - * When a host accepts side-chat creation from an active turn, it snapshots the - * source chat's retained history plus that turn's current user message and any - * partial assistant response already available. Later source-turn deltas do not - * retroactively change the created side chat's starting context. - * - * @category Chat State - */ -export interface ActiveChatSourceTurn { - /** Discriminant */ - kind: ChatSourceTurnKind.Active; - /** Active turn identifier in the source chat. */ - turnId: string; -} - -/** - * A source-turn reference used by chat creation and durable chat origin state. - * - * @category Chat State - */ -export type ChatSourceTurn = - | CompletedChatSourceTurn - | ActiveChatSourceTurn; - /** * How a chat came into existence. Clients MAY use it to render * contextual UI (parent indicators, fork markers, "spawned by tool" badges). * * Fork origins preserve the existing flat `chat` + `turnId` wire shape. Side - * chat origins use `turn.kind` to distinguish completed-turn provenance from - * active-turn snapshot provenance. + * chat origins likewise carry a stable `turnId` identity alongside + * `kind: "sideChat"` instead of snapshotting whether that turn was active or + * historical at creation time. Consumers resolve the identifier against the + * source chat's current `activeTurn` or retained `turns` as needed. + * + * When a host accepts side-chat creation from the source chat's current active + * turn, it snapshots the retained history plus that turn's current user + * message and any partial assistant response already available. Later + * source-turn deltas do not retroactively change the created side chat's + * starting context, and once the source turn completes it is still referenced + * by the same `turnId`. * * The `tool` variant records a tool-spawned worker from the worker's side: its * `chat`/`toolCallId` identify the spawning tool call in the parent chat. This @@ -252,7 +209,7 @@ export type ChatSourceTurn = export type ChatOrigin = | { kind: ChatOriginKind.User } | { kind: ChatOriginKind.Fork; chat: URI; turnId: string } - | { kind: ChatOriginKind.SideChat; chat: URI; turn: ChatSourceTurn } + | { kind: ChatOriginKind.SideChat; chat: URI; turnId: string } | { kind: ChatOriginKind.Tool; chat: URI; toolCallId: string }; /** diff --git a/types/channels-root/state.ts b/types/channels-root/state.ts index a2e73f6b1..a7de4c0fa 100644 --- a/types/channels-root/state.ts +++ b/types/channels-root/state.ts @@ -143,10 +143,11 @@ export interface MultipleChatsCapability { * `kind: "sideChat"` to `createChat`. * * A side chat receives the source turn as context without copying the source - * transcript into its own visible history. The source may be a completed turn - * or the source chat's current active turn; when active, the host snapshots - * the available partial assistant response at creation time. Side-chat - * support always implies multi-chat support. + * transcript into its own visible history. The source is identified by a + * stable `turnId`, which the host resolves against the source chat's current + * `activeTurn` or retained history. When it names the current active turn, + * the host snapshots the available partial assistant response at creation + * time. Side-chat support always implies multi-chat support. */ sideChat?: boolean; } diff --git a/types/index.ts b/types/index.ts index 604e27646..f7e689ed6 100644 --- a/types/index.ts +++ b/types/index.ts @@ -26,9 +26,6 @@ export type { ChatState, ChatSummary, ChatOrigin, - ChatSourceTurn, - CompletedChatSourceTurn, - ActiveChatSourceTurn, ChangesSummary, SessionConfigState, Turn, @@ -132,7 +129,6 @@ export { SessionStatus, SessionInputRequestKind, ChatOriginKind, - ChatSourceTurnKind, TurnState, MessageKind, MessageAttachmentKind, diff --git a/types/test-cases/round-trips/031-side-chat-origin.json b/types/test-cases/round-trips/031-side-chat-origin.json index 2702ce65f..8d177bc3c 100644 --- a/types/test-cases/round-trips/031-side-chat-origin.json +++ b/types/test-cases/round-trips/031-side-chat-origin.json @@ -1,7 +1,7 @@ { "name": "side-chat-origin", "group": "A", - "description": "A session/chatAdded action preserves completed-turn side-chat provenance through the ChatOrigin discriminated union.", + "description": "A session/chatAdded action preserves stable side-chat turn identities for historical source turns.", "type": "StateAction", "input": { "type": "session/chatAdded", @@ -13,10 +13,7 @@ "origin": { "kind": "sideChat", "chat": "ahp-chat:/main", - "turn": { - "kind": "completed", - "turnId": "turn-12" - } + "turnId": "turn-12" } } }, @@ -31,10 +28,7 @@ "origin": { "kind": "sideChat", "chat": "ahp-chat:/main", - "turn": { - "kind": "completed", - "turnId": "turn-12" - } + "turnId": "turn-12" } } } diff --git a/types/test-cases/round-trips/033-chat-source-side-chat.json b/types/test-cases/round-trips/033-chat-source-side-chat.json index e8eda37e2..4ebe84348 100644 --- a/types/test-cases/round-trips/033-chat-source-side-chat.json +++ b/types/test-cases/round-trips/033-chat-source-side-chat.json @@ -1,24 +1,18 @@ { "name": "chat-source-side-chat", "group": "A", - "description": "ChatSource requires an explicit sideChat discriminator alongside its source chat and completed-turn snapshot.", + "description": "ChatSource requires an explicit sideChat discriminator alongside its source chat and stable historical turnId.", "type": "ChatSource", "input": { "kind": "sideChat", "chat": "ahp-chat:/main", - "turn": { - "kind": "completed", - "turnId": "turn-12" - } + "turnId": "turn-12" }, "acceptableOutputs": [ { "kind": "sideChat", "chat": "ahp-chat:/main", - "turn": { - "kind": "completed", - "turnId": "turn-12" - } + "turnId": "turn-12" } ] } diff --git a/types/test-cases/round-trips/034-side-chat-origin-active.json b/types/test-cases/round-trips/034-side-chat-origin-active.json index e35a8703d..2c00900d1 100644 --- a/types/test-cases/round-trips/034-side-chat-origin-active.json +++ b/types/test-cases/round-trips/034-side-chat-origin-active.json @@ -1,7 +1,7 @@ { "name": "side-chat-origin-active", "group": "A", - "description": "A session/chatAdded action preserves active-turn side-chat provenance through the ChatOrigin discriminated union.", + "description": "A session/chatAdded action preserves stable side-chat turn identities even when the source was active at creation time.", "type": "StateAction", "input": { "type": "session/chatAdded", @@ -13,10 +13,7 @@ "origin": { "kind": "sideChat", "chat": "ahp-chat:/main", - "turn": { - "kind": "active", - "turnId": "turn-active" - } + "turnId": "turn-active" } } }, @@ -31,10 +28,7 @@ "origin": { "kind": "sideChat", "chat": "ahp-chat:/main", - "turn": { - "kind": "active", - "turnId": "turn-active" - } + "turnId": "turn-active" } } } diff --git a/types/test-cases/round-trips/035-chat-source-side-chat-active.json b/types/test-cases/round-trips/035-chat-source-side-chat-active.json index 4038b232f..11c5062da 100644 --- a/types/test-cases/round-trips/035-chat-source-side-chat-active.json +++ b/types/test-cases/round-trips/035-chat-source-side-chat-active.json @@ -1,24 +1,18 @@ { "name": "chat-source-side-chat-active", "group": "A", - "description": "ChatSource allows an explicit active-turn sideChat source without weakening the completed-turn fork contract.", + "description": "ChatSource allows a sideChat source to name the currently active turn by stable turnId without weakening the completed-turn fork contract.", "type": "ChatSource", "input": { "kind": "sideChat", "chat": "ahp-chat:/main", - "turn": { - "kind": "active", - "turnId": "turn-active" - } + "turnId": "turn-active" }, "acceptableOutputs": [ { "kind": "sideChat", "chat": "ahp-chat:/main", - "turn": { - "kind": "active", - "turnId": "turn-active" - } + "turnId": "turn-active" } ] } From 7ba222654e17a7d050ff90026fb5ba6e0d469acc Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 23 Jul 2026 09:04:11 -0700 Subject: [PATCH 6/9] chat: disambiguate chat source schema Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- schema/commands.schema.json | 13 +++- schema/errors.schema.json | 13 +++- scripts/generate-json-schema.test.ts | 112 +++++++++++++++++++++++++++ scripts/generate-json-schema.ts | 107 ++++++++++++++++++++++++- 4 files changed, 240 insertions(+), 5 deletions(-) diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 48fe7e6ed..6a7267504 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -8656,7 +8656,18 @@ "ChatSource": { "oneOf": [ { - "$ref": "#/$defs/ForkChatSource" + "allOf": [ + { + "$ref": "#/$defs/ForkChatSource" + }, + { + "not": { + "required": [ + "kind" + ] + } + } + ] }, { "$ref": "#/$defs/SideChatSource" diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 55df5f786..f77dbb36e 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -7235,7 +7235,18 @@ "ChatSource": { "oneOf": [ { - "$ref": "#/$defs/ForkChatSource" + "allOf": [ + { + "$ref": "#/$defs/ForkChatSource" + }, + { + "not": { + "required": [ + "kind" + ] + } + } + ] }, { "$ref": "#/$defs/SideChatSource" diff --git a/scripts/generate-json-schema.test.ts b/scripts/generate-json-schema.test.ts index 327e9fb1f..8852c3f83 100644 --- a/scripts/generate-json-schema.test.ts +++ b/scripts/generate-json-schema.test.ts @@ -43,6 +43,8 @@ function collectRefTargets(node: JsonNode, acc: Set): void { const m = ref.match(/^#\/\$defs\/(.+)$/); if (m) acc.add(m[1]); } + const not = (node as Record).not; + if (not) collectRefTargets(not as JsonNode, acc); for (const value of Object.values(node)) collectRefTargets(value as JsonNode, acc); } } @@ -66,6 +68,84 @@ function countEmptyOneOfBranches(node: JsonNode): number { return count; } +function dereferenceSchema( + root: Record, + schema: Record, +): Record { + const ref = schema.$ref; + if (typeof ref !== 'string') { + return schema; + } + const match = ref.match(/^#\/\$defs\/(.+)$/); + assert.ok(match, `unsupported schema ref: ${ref}`); + const defs = root.$defs as Record>; + const resolved = defs[match[1]]; + assert.ok(resolved, `missing schema def for ${ref}`); + return resolved; +} + +function schemaAccepts( + root: Record, + node: JsonNode, + value: unknown, +): boolean { + if (!node || typeof node !== 'object' || Array.isArray(node)) { + return true; + } + + const schema = dereferenceSchema(root, node as Record); + const oneOf = schema.oneOf; + if (Array.isArray(oneOf)) { + return oneOf.filter(branch => schemaAccepts(root, branch as JsonNode, value)).length === 1; + } + + const allOf = schema.allOf; + if (Array.isArray(allOf)) { + return allOf.every(branch => schemaAccepts(root, branch as JsonNode, value)); + } + + if (schema.not) { + return !schemaAccepts(root, schema.not as JsonNode, value); + } + + if ('const' in schema) { + return value === schema.const; + } + + if (schema.type === 'object' || schema.required || schema.properties) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const record = value as Record; + const required = Array.isArray(schema.required) ? schema.required : []; + if (required.some((property) => !(property in record))) { + return false; + } + const properties = (schema.properties as Record | undefined) ?? {}; + return Object.entries(properties).every(([name, propertySchema]) => { + if (!(name in record)) { + return true; + } + return schemaAccepts(root, propertySchema, record[name]); + }); + } + + switch (schema.type) { + case 'string': + return typeof value === 'string'; + case 'number': + return typeof value === 'number'; + case 'boolean': + return typeof value === 'boolean'; + case 'null': + return value === null; + case 'array': + return Array.isArray(value); + } + + return true; +} + describe('generated JSON schemas', () => { for (const file of SCHEMA_FILES) { describe(file, () => { @@ -147,6 +227,38 @@ describe('generated JSON schemas', () => { ); } }); + + it('validates ChatSource kind disambiguation', () => { + const defs = schema.$defs as Record>; + const chatSource = defs.ChatSource; + if (!chatSource) { + return; + } + + assert.equal( + schemaAccepts(schema, chatSource, { + kind: 'sideChat', + chat: 'ahp-chat:/source', + turnId: 'turn-1', + }), + true, + ); + assert.equal( + schemaAccepts(schema, chatSource, { + chat: 'ahp-chat:/source', + turnId: 'turn-1', + }), + true, + ); + assert.equal( + schemaAccepts(schema, chatSource, { + kind: 'fork', + chat: 'ahp-chat:/source', + turnId: 'turn-1', + }), + false, + ); + }); }); } }); diff --git a/scripts/generate-json-schema.ts b/scripts/generate-json-schema.ts index e899ecb0e..13a35abac 100644 --- a/scripts/generate-json-schema.ts +++ b/scripts/generate-json-schema.ts @@ -28,12 +28,14 @@ interface JsonSchema { type?: string; properties?: Record; required?: string[]; - additionalProperties?: boolean; + additionalProperties?: boolean | JsonSchema; items?: JsonSchema; enum?: Array; const?: string | number | boolean; oneOf?: JsonSchema[]; + allOf?: JsonSchema[]; anyOf?: JsonSchema[]; + not?: JsonSchema; $ref?: string; $defs?: Record; } @@ -124,6 +126,97 @@ function getAllInterfaceProperties(iface: InterfaceDeclaration, project: Project return [...byName.values()]; } +function resolveSchemaForInspection(schema: JsonSchema, project: Project): JsonSchema | undefined { + if (!schema.$ref) return schema; + const match = schema.$ref.match(/^#\/\$defs\/(.+)$/); + if (!match) return undefined; + return buildDefForName(project, match[1]); +} + +function getObjectSchemaMetadata( + schema: JsonSchema, + project: Project, +): { properties: Record; required: Set } | undefined { + const resolved = resolveSchemaForInspection(schema, project); + if (!resolved || resolved.type !== 'object' || !resolved.properties) return undefined; + return { + properties: resolved.properties, + required: new Set(resolved.required ?? []), + }; +} + +function makeUnionBranchesMutuallyExclusive( + branches: JsonSchema[], + project: Project, +): JsonSchema[] { + const metadata = branches.map(branch => getObjectSchemaMetadata(branch, project)); + const objectMetadata = metadata.filter( + (info): info is { properties: Record; required: Set } => !!info, + ); + if (objectMetadata.length !== metadata.length) { + return branches; + } + + const candidateProperties = new Set(); + for (const info of objectMetadata) { + for (const [name, schema] of Object.entries(info.properties)) { + if (info.required.has(name) && schema.const !== undefined) { + candidateProperties.add(name); + } + } + } + + for (const propertyName of candidateProperties) { + let hasLegacyBranch = false; + let hasDiscriminatedBranch = false; + const discriminantValues = new Set(); + let supported = true; + + for (const info of objectMetadata) { + const propertySchema = info.properties[propertyName]; + if (!propertySchema) { + hasLegacyBranch = true; + continue; + } + + if (!info.required.has(propertyName) || propertySchema.const === undefined) { + supported = false; + break; + } + + if (discriminantValues.has(propertySchema.const)) { + supported = false; + break; + } + + discriminantValues.add(propertySchema.const); + hasDiscriminatedBranch = true; + } + + if (!supported || !hasLegacyBranch || !hasDiscriminatedBranch) { + continue; + } + + return branches.map((branch, index) => { + if (objectMetadata[index].properties[propertyName]) { + return branch; + } + return { + allOf: [ + branch, + { + not: { + required: [propertyName], + }, + }, + ], + }; + }); + } + + return branches; +} + // ─── Type → JSON Schema Conversion ────────────────────────────────────────── function typeTextToSchema(typeText: string, project: Project, _depth = 0): JsonSchema { @@ -203,7 +296,12 @@ function typeTextToSchema(typeText: string, project: Project, _depth = 0): JsonS if (cleaned.includes('|') && !cleaned.startsWith("'")) { const parts = splitUnionType(cleaned).filter(p => p !== 'undefined' && p !== ''); if (parts.length > 1) { - return { oneOf: parts.map(p => typeTextToSchema(p, project, _depth + 1)) }; + return { + oneOf: makeUnionBranchesMutuallyExclusive( + parts.map(p => typeTextToSchema(p, project, _depth + 1)), + project, + ), + }; } if (parts.length === 1 && parts[0] !== cleaned) { return typeTextToSchema(parts[0], project, _depth + 1); @@ -586,7 +684,10 @@ function collectRefTargets(node: JsonSchema | undefined, acc: Set): void if (node.$defs) { for (const key of Object.keys(node.$defs)) collectRefTargets(node.$defs[key], acc); } - for (const branch of [node.oneOf, node.anyOf]) { + if (node.not) { + collectRefTargets(node.not, acc); + } + for (const branch of [node.oneOf, node.allOf, node.anyOf]) { if (branch) for (const b of branch) collectRefTargets(b, acc); } } From 746e5a3bce414a776af648b1ebef219c8edab6ed Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 23 Jul 2026 12:40:00 -0700 Subject: [PATCH 7/9] chat: discriminate fork sources Require fork chat sources to carry the same stable kind/turnId shape as side chats across source types, schema, docs, and generated clients. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahptypes/chat_source_test.go | 55 +++++++ clients/go/ahptypes/commands.generated.go | 85 +++++------ clients/go/ahptypes/common.go | 8 + clients/go/ahptypes/state.generated.go | 4 +- .../generated/Commands.generated.kt | 58 ++++---- .../generated/State.generated.kt | 4 +- .../DiscriminatedUnionTest.kt | 47 ++++++ clients/rust/crates/ahp-types/src/commands.rs | 70 ++------- clients/rust/crates/ahp-types/src/state.rs | 4 +- .../crates/ahp-types/tests/chat_source.rs | 42 ++++++ .../Generated/Commands.generated.swift | 50 ++++--- .../Generated/State.generated.swift | 4 +- .../ChatSourceTests.swift | 51 +++++++ ...260722-chat-discriminated-fork-source.json | 4 + .../20260722-chat-legacy-fork-source.json | 4 - docs/guide/state-model.md | 12 +- docs/proposals/multi-chat.md | 13 +- docs/specification/chat-channel.md | 13 +- schema/actions.schema.json | 4 +- schema/commands.schema.json | 30 ++-- schema/errors.schema.json | 30 ++-- schema/notifications.schema.json | 4 +- schema/state.schema.json | 4 +- scripts/generate-go.ts | 76 +++------- scripts/generate-json-schema.test.ts | 51 +++---- scripts/generate-json-schema.ts | 96 +----------- scripts/generate-kotlin.ts | 58 ++------ scripts/generate-rust.ts | 139 ++---------------- scripts/generate-swift.ts | 39 ++--- types/channels-chat/commands.ts | 31 ++-- types/channels-chat/state.ts | 8 +- types/channels-root/state.ts | 4 +- ...rk-flat.json => 036-chat-source-fork.json} | 6 +- ...rk-flat.json => 037-chat-origin-fork.json} | 4 +- 34 files changed, 475 insertions(+), 637 deletions(-) create mode 100644 clients/go/ahptypes/chat_source_test.go create mode 100644 clients/rust/crates/ahp-types/tests/chat_source.rs create mode 100644 clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ChatSourceTests.swift create mode 100644 docs/.changes/20260722-chat-discriminated-fork-source.json delete mode 100644 docs/.changes/20260722-chat-legacy-fork-source.json rename types/test-cases/round-trips/{036-chat-source-fork-flat.json => 036-chat-source-fork.json} (51%) rename types/test-cases/round-trips/{037-chat-origin-fork-flat.json => 037-chat-origin-fork.json} (84%) diff --git a/clients/go/ahptypes/chat_source_test.go b/clients/go/ahptypes/chat_source_test.go new file mode 100644 index 000000000..3184b4e10 --- /dev/null +++ b/clients/go/ahptypes/chat_source_test.go @@ -0,0 +1,55 @@ +package ahptypes + +import ( + "encoding/json" + "fmt" + "testing" +) + +func TestChatSourceRoutesByKind(t *testing.T) { + t.Run("fork", func(t *testing.T) { + var value ChatSource + if err := json.Unmarshal([]byte(`{"kind":"fork","chat":"ahp-chat:/main","turnId":"turn-12"}`), &value); err != nil { + t.Fatalf("decode fork: %v", err) + } + fork, ok := value.Value.(*ForkChatSource) + if !ok { + t.Fatalf("expected *ForkChatSource, got %T", value.Value) + } + if fork.Kind != ChatSourceKindFork || fork.TurnId != "turn-12" { + t.Fatalf("unexpected fork payload: %#v", fork) + } + }) + + t.Run("sideChat", func(t *testing.T) { + var value ChatSource + if err := json.Unmarshal([]byte(`{"kind":"sideChat","chat":"ahp-chat:/main","turnId":"turn-active"}`), &value); err != nil { + t.Fatalf("decode sideChat: %v", err) + } + sideChat, ok := value.Value.(*SideChatSource) + if !ok { + t.Fatalf("expected *SideChatSource, got %T", value.Value) + } + if sideChat.Kind != ChatSourceKindSideChat || sideChat.TurnId != "turn-active" { + t.Fatalf("unexpected sideChat payload: %#v", sideChat) + } + }) +} + +func TestChatSourceRejectsMissingOrUnknownKind(t *testing.T) { + for name, raw := range map[string]string{ + "missing kind": `{"chat":"ahp-chat:/main","turnId":"turn-12"}`, + "unknown kind": `{"kind":"future","chat":"ahp-chat:/main","turnId":"turn-12"}`, + } { + t.Run(name, func(t *testing.T) { + var value ChatSource + err := json.Unmarshal([]byte(raw), &value) + if err == nil { + t.Fatalf("expected decode failure for %s", name) + } + if got := fmt.Sprint(err); got == "" { + t.Fatalf("expected printable error for %s", name) + } + }) + } +} diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index 399648272..b3a9fb515 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -386,13 +386,13 @@ type DisposeSessionParams struct { // Copies source history through a completed turn into the new chat. type ForkChatSource struct { + // Discriminant + Kind ChatSourceKind `json:"kind"` // URI of the existing source chat. Chat URI `json:"chat"` // Completed turn identifier in the source chat. // // Content through this turn is copied into the new chat's visible `turns`. - // This preserves the existing 0.7.x flat fork wire format (`chat` + - // `turnId`). TurnId string `json:"turnId"` } @@ -424,23 +424,21 @@ type CreateChatParams struct { InitialMessage *Message `json:"initialMessage,omitempty"` // Optional source chat and source turn. // - // The source chat MUST belong to this session. Clients MUST only request the - // flat fork shape (`chat` + `turnId`) when the selected agent advertises - // `capabilities.multipleChats.fork`, and - // `kind: "sideChat"` when the selected agent advertises - // `capabilities.multipleChats.sideChat`. Forks keep the legacy flat - // `chat` + `turnId` shape and therefore only target completed turns. Side - // chats also carry a stable `turnId`, which the host resolves against the - // source chat's current active turn or retained history. If it resolves to - // the active turn, the host snapshots the currently available partial - // response when accepting `createChat`. + // The source chat MUST belong to this session. Clients MUST only request + // `kind: "fork"` when the selected agent advertises + // `capabilities.multipleChats.fork`, and `kind: "sideChat"` when the + // selected agent advertises `capabilities.multipleChats.sideChat`. Both + // source forms carry a stable top-level `turnId`. Forks target completed + // turns. Side chats also carry a stable `turnId`, which the host resolves + // against the source chat's current active turn or retained history. If it + // resolves to the active turn, the host snapshots the currently available + // partial response when accepting `createChat`. Source *ChatSource `json:"source,omitempty"` // Initial working-directory subset for this chat. Every entry MUST be // present in the owning session's `workingDirectories`; the server MUST // reject any entry that is not. When absent, the chat inherits the full - // session set. Forked chats (those whose `source` uses the flat `chat` + - // `turnId` shape) inherit the source chat's `workingDirectories`; this field - // is ignored for forks. + // session set. Forked chats (those whose `source.kind` is `"fork"`) inherit + // the source chat's `workingDirectories`; this field is ignored for forks. // // A client MUST NOT supply this field unless the agent advertises // {@link AgentCapabilities.multipleWorkingDirectories}. @@ -453,8 +451,7 @@ type CreateChatParams struct { // reject creation that omits it, or fall back to the first of the chat's // directories. Fixed at creation and reported (read-only) on // {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose - // `source` uses the flat `chat` + `turnId` shape inherits the source chat's - // primary). + // `source.kind` is `"fork"` inherits the source chat's primary). PrimaryWorkingDirectory *URI `json:"primaryWorkingDirectory,omitempty"` } @@ -1129,46 +1126,47 @@ type ChatSource struct { Value isChatSource } -// isChatSource is the marker interface for chat source variants. +// isChatSource is the marker interface implemented by every +// concrete variant of ChatSource. type isChatSource interface{ isChatSource() } func (*ForkChatSource) isChatSource() {} func (*SideChatSource) isChatSource() {} -// UnmarshalJSON decodes side-chat sources by `kind: "sideChat"`. Any other -// object without a `kind` is treated as the legacy flat fork payload. -func (s *ChatSource) UnmarshalJSON(data []byte) error { +// UnmarshalJSON decodes the variant indicated by the "kind" discriminator. +func (u *ChatSource) UnmarshalJSON(data []byte) error { disc, ok, err := readDiscriminator(data, "kind") if err != nil { return err } - if ok { - switch disc { - case "sideChat": - var value SideChatSource - if err := json.Unmarshal(data, &value); err != nil { - return err - } - s.Value = &value - return nil - default: - return &json.UnmarshalTypeError{Value: "ChatSource kind " + disc, Type: nil} - } + if !ok { + return missingDiscriminatorError("ChatSource", "kind") } - var value ForkChatSource - if err := json.Unmarshal(data, &value); err != nil { - return err + switch disc { + case "fork": + var value ForkChatSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "sideChat": + var value SideChatSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + default: + return unknownDiscriminatorError("ChatSource", "kind", disc) } - s.Value = &value return nil } // MarshalJSON encodes the active variant back to JSON. -func (s ChatSource) MarshalJSON() ([]byte, error) { - if s.Value == nil { +func (u ChatSource) MarshalJSON() ([]byte, error) { + if u.Value == nil { return []byte("null"), nil } - return json.Marshal(s.Value) + return json.Marshal(u.Value) } // ─── ReconnectResult Union ──────────────────────────────────────────── @@ -1187,10 +1185,13 @@ func (*ReconnectSnapshotResult) isReconnectResult() {} // UnmarshalJSON decodes the variant indicated by the "type" discriminator. func (u *ReconnectResult) UnmarshalJSON(data []byte) error { - disc, _, err := readDiscriminator(data, "type") + disc, ok, err := readDiscriminator(data, "type") if err != nil { return err } + if !ok { + return missingDiscriminatorError("ReconnectResult", "type") + } switch disc { case "replay": var value ReconnectReplayResult @@ -1205,7 +1206,7 @@ func (u *ReconnectResult) UnmarshalJSON(data []byte) error { } u.Value = &value default: - return &json.UnmarshalTypeError{Value: "ReconnectResult", Type: nil} + return unknownDiscriminatorError("ReconnectResult", "type", disc) } return nil } diff --git a/clients/go/ahptypes/common.go b/clients/go/ahptypes/common.go index 233051bbf..9b4dddc29 100644 --- a/clients/go/ahptypes/common.go +++ b/clients/go/ahptypes/common.go @@ -197,3 +197,11 @@ func readDiscriminator(raw []byte, field string) (string, bool, error) { } return s, true, nil } + +func missingDiscriminatorError(typeName string, field string) error { + return fmt.Errorf("%s: missing %q discriminator", typeName, field) +} + +func unknownDiscriminatorError(typeName string, field string, value string) error { + return fmt.Errorf("%s: unknown %q discriminator %q", typeName, field, value) +} diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 0307195e8..eb9b230b1 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -603,8 +603,8 @@ type AgentCapabilities struct { // Options for the {@link AgentCapabilities.multipleChats} capability. type MultipleChatsCapability struct { // The agent can fork a chat from a specific turn. When absent or `false`, - // clients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`) - // to `createChat`. + // clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to + // `createChat`. // Forking always implies multi-chat support. Fork *bool `json:"fork,omitempty"` // The agent can create a side chat from a specific turn. When absent or 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 2a6242eb8..d9aa507ce 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 @@ -468,6 +468,10 @@ data class DisposeSessionParams( @Serializable data class ForkChatSource( + /** + * Discriminant + */ + val kind: ChatSourceKind, /** * URI of the existing source chat. */ @@ -476,8 +480,6 @@ data class ForkChatSource( * Completed turn identifier in the source chat. * * Content through this turn is copied into the new chat's visible `turns`. - * This preserves the existing 0.7.x flat fork wire format (`chat` + - * `turnId`). */ val turnId: String ) @@ -522,25 +524,23 @@ data class CreateChatParams( /** * Optional source chat and source turn. * - * The source chat MUST belong to this session. Clients MUST only request the - * flat fork shape (`chat` + `turnId`) when the selected agent advertises - * `capabilities.multipleChats.fork`, and - * `kind: "sideChat"` when the selected agent advertises - * `capabilities.multipleChats.sideChat`. Forks keep the legacy flat - * `chat` + `turnId` shape and therefore only target completed turns. Side - * chats also carry a stable `turnId`, which the host resolves against the - * source chat's current active turn or retained history. If it resolves to - * the active turn, the host snapshots the currently available partial - * response when accepting `createChat`. + * The source chat MUST belong to this session. Clients MUST only request + * `kind: "fork"` when the selected agent advertises + * `capabilities.multipleChats.fork`, and `kind: "sideChat"` when the + * selected agent advertises `capabilities.multipleChats.sideChat`. Both + * source forms carry a stable top-level `turnId`. Forks target completed + * turns. Side chats also carry a stable `turnId`, which the host resolves + * against the source chat's current active turn or retained history. If it + * resolves to the active turn, the host snapshots the currently available + * partial response when accepting `createChat`. */ val source: ChatSource? = null, /** * Initial working-directory subset for this chat. Every entry MUST be * present in the owning session's `workingDirectories`; the server MUST * reject any entry that is not. When absent, the chat inherits the full - * session set. Forked chats (those whose `source` uses the flat `chat` + - * `turnId` shape) inherit the source chat's `workingDirectories`; this field - * is ignored for forks. + * session set. Forked chats (those whose `source.kind` is `"fork"`) inherit + * the source chat's `workingDirectories`; this field is ignored for forks. * * A client MUST NOT supply this field unless the agent advertises * {@link AgentCapabilities.multipleWorkingDirectories}. @@ -555,8 +555,7 @@ data class CreateChatParams( * reject creation that omits it, or fall back to the first of the chat's * directories. Fixed at creation and reported (read-only) on * {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose - * `source` uses the flat `chat` + `turnId` shape inherits the source chat's - * primary). + * `source.kind` is `"fork"` inherits the source chat's primary). */ val primaryWorkingDirectory: String? = null ) @@ -1300,12 +1299,10 @@ data class ChangesetOperationFollowUp( // ─── ChatSource Union ─────────────────────────────────────────────────────── @Serializable(with = ChatSourceSerializer::class) -sealed interface ChatSource { -} +sealed interface ChatSource @JvmInline value class ChatSourceFork(val value: ForkChatSource) : ChatSource - @JvmInline value class ChatSourceSideChat(val value: SideChatSource) : ChatSource @@ -1319,15 +1316,12 @@ internal object ChatSourceSerializer : KSerializer { val element = input.decodeJsonElement() val obj = element as? JsonObject ?: error("Expected JsonObject for ChatSource") - val kind = (obj["kind"] as? JsonPrimitive)?.contentOrNull - return when (kind) { - "sideChat" -> ChatSourceSideChat( - input.json.decodeFromJsonElement(SideChatSource.serializer(), element), - ) - null -> ChatSourceFork( - input.json.decodeFromJsonElement(ForkChatSource.serializer(), element), - ) - else -> error("Unknown ChatSource discriminator: $kind") + val discriminant = (obj["kind"] as? JsonPrimitive)?.content + ?: error("Missing kind discriminator on ChatSource") + return when (discriminant) { + "fork" -> ChatSourceFork(input.json.decodeFromJsonElement(ForkChatSource.serializer(), element)) + "sideChat" -> ChatSourceSideChat(input.json.decodeFromJsonElement(SideChatSource.serializer(), element)) + else -> error("Unknown ChatSource discriminator: $discriminant") } } @@ -1335,10 +1329,8 @@ internal object ChatSourceSerializer : KSerializer { val output = encoder as? JsonEncoder ?: error("ChatSource can only be serialized to JSON") val element: JsonElement = when (value) { - is ChatSourceFork -> - output.json.encodeToJsonElement(ForkChatSource.serializer(), value.value) - is ChatSourceSideChat -> - output.json.encodeToJsonElement(SideChatSource.serializer(), value.value) + is ChatSourceFork -> output.json.encodeToJsonElement(ForkChatSource.serializer(), value.value) + is ChatSourceSideChat -> output.json.encodeToJsonElement(SideChatSource.serializer(), value.value) } output.encodeJsonElement(element) } 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 baffa3592..8bb91b382 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 @@ -981,8 +981,8 @@ data class AgentCapabilities( data class MultipleChatsCapability( /** * The agent can fork a chat from a specific turn. When absent or `false`, - * clients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`) - * to `createChat`. + * clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to + * `createChat`. * Forking always implies multi-chat support. */ val fork: Boolean? = null, diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt index d8ec4f598..aaaafaf97 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt @@ -18,6 +18,12 @@ import com.microsoft.agenthostprotocol.generated.ChatInputNumberQuestion import com.microsoft.agenthostprotocol.generated.ChatInputQuestion import com.microsoft.agenthostprotocol.generated.ChatInputQuestionNumber import com.microsoft.agenthostprotocol.generated.ChatInputQuestionKind +import com.microsoft.agenthostprotocol.generated.ChatSource +import com.microsoft.agenthostprotocol.generated.ChatSourceFork +import com.microsoft.agenthostprotocol.generated.ChatSourceKind +import com.microsoft.agenthostprotocol.generated.ChatSourceSideChat +import com.microsoft.agenthostprotocol.generated.ForkChatSource +import com.microsoft.agenthostprotocol.generated.SideChatSource import com.microsoft.agenthostprotocol.generated.StringOrMarkdown import com.microsoft.agenthostprotocol.generated.ToolResultContent import kotlinx.serialization.json.JsonObject @@ -26,6 +32,7 @@ import kotlinx.serialization.json.jsonObject import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertFailsWith import kotlin.test.assertTrue /** @@ -116,6 +123,46 @@ class DiscriminatedUnionTest { assertEquals(JsonPrimitive("integer"), reObj["kind"]) } + @Test + fun `ChatSource fork and sideChat route by kind`() { + val forkWire = """{"kind":"fork","chat":"ahp-chat:/main","turnId":"turn-12"}""" + val sideChatWire = """{"kind":"sideChat","chat":"ahp-chat:/main","turnId":"turn-active"}""" + + val fork = json.decodeFromString(ChatSource.serializer(), forkWire) + val sideChat = json.decodeFromString(ChatSource.serializer(), sideChatWire) + + val asFork = assertIs(fork) + val asSideChat = assertIs(sideChat) + assertEquals(ChatSourceKind.FORK, asFork.value.kind) + assertEquals("turn-12", asFork.value.turnId) + assertEquals(ChatSourceKind.SIDE_CHAT, asSideChat.value.kind) + assertEquals("turn-active", asSideChat.value.turnId) + + val reEncodedFork = json.encodeToString( + ChatSource.serializer(), + ChatSourceFork(ForkChatSource(kind = ChatSourceKind.FORK, chat = "ahp-chat:/main", turnId = "turn-12")), + ) + val reEncodedSideChat = json.encodeToString( + ChatSource.serializer(), + ChatSourceSideChat(SideChatSource(kind = ChatSourceKind.SIDE_CHAT, chat = "ahp-chat:/main", turnId = "turn-active")), + ) + assertEquals(JsonPrimitive("fork"), json.parseToJsonElement(reEncodedFork).jsonObject["kind"]) + assertEquals(JsonPrimitive("sideChat"), json.parseToJsonElement(reEncodedSideChat).jsonObject["kind"]) + } + + @Test + fun `ChatSource rejects missing or unknown kind`() { + val missingKind = """{"chat":"ahp-chat:/main","turnId":"turn-12"}""" + val unknownKind = """{"kind":"future","chat":"ahp-chat:/main","turnId":"turn-12"}""" + + assertFailsWith { + json.decodeFromString(ChatSource.serializer(), missingKind) + } + assertFailsWith { + json.decodeFromString(ChatSource.serializer(), unknownKind) + } + } + @Test fun `StringOrMarkdown decodes plain string form`() { val plain = json.decodeFromString(StringOrMarkdown.serializer(), "\"hi there\"") diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index 4a7cffc69..1278cb28a 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -488,8 +488,6 @@ pub struct ForkChatSource { /// Completed turn identifier in the source chat. /// /// Content through this turn is copied into the new chat's visible `turns`. - /// This preserves the existing 0.7.x flat fork wire format (`chat` + - /// `turnId`). pub turn_id: String, } @@ -498,8 +496,6 @@ pub struct ForkChatSource { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SideChatSource { - /// Discriminant - pub kind: ChatSourceKind, /// URI of the existing source chat. pub chat: Uri, /// Stable source-turn identifier in the source chat. @@ -526,24 +522,22 @@ pub struct CreateChatParams { pub initial_message: Option, /// Optional source chat and source turn. /// - /// The source chat MUST belong to this session. Clients MUST only request the - /// flat fork shape (`chat` + `turnId`) when the selected agent advertises - /// `capabilities.multipleChats.fork`, and - /// `kind: "sideChat"` when the selected agent advertises - /// `capabilities.multipleChats.sideChat`. Forks keep the legacy flat - /// `chat` + `turnId` shape and therefore only target completed turns. Side - /// chats also carry a stable `turnId`, which the host resolves against the - /// source chat's current active turn or retained history. If it resolves to - /// the active turn, the host snapshots the currently available partial - /// response when accepting `createChat`. + /// The source chat MUST belong to this session. Clients MUST only request + /// `kind: "fork"` when the selected agent advertises + /// `capabilities.multipleChats.fork`, and `kind: "sideChat"` when the + /// selected agent advertises `capabilities.multipleChats.sideChat`. Both + /// source forms carry a stable top-level `turnId`. Forks target completed + /// turns. Side chats also carry a stable `turnId`, which the host resolves + /// against the source chat's current active turn or retained history. If it + /// resolves to the active turn, the host snapshots the currently available + /// partial response when accepting `createChat`. #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, /// Initial working-directory subset for this chat. Every entry MUST be /// present in the owning session's `workingDirectories`; the server MUST /// reject any entry that is not. When absent, the chat inherits the full - /// session set. Forked chats (those whose `source` uses the flat `chat` + - /// `turnId` shape) inherit the source chat's `workingDirectories`; this field - /// is ignored for forks. + /// session set. Forked chats (those whose `source.kind` is `"fork"`) inherit + /// the source chat's `workingDirectories`; this field is ignored for forks. /// /// A client MUST NOT supply this field unless the agent advertises /// {@link AgentCapabilities.multipleWorkingDirectories}. @@ -557,8 +551,7 @@ pub struct CreateChatParams { /// reject creation that omits it, or fall back to the first of the chat's /// directories. Fixed at creation and reported (read-only) on /// {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose - /// `source` uses the flat `chat` + `turnId` shape inherits the source chat's - /// primary). + /// `source.kind` is `"fork"` inherits the source chat's primary). #[serde(default, skip_serializing_if = "Option::is_none")] pub primary_working_directory: Option, } @@ -1353,47 +1346,14 @@ pub struct ChangesetOperationFollowUp { /// How a new chat uses a source chat. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(try_from = "serde_json::Value", into = "serde_json::Value")] +#[serde(tag = "kind")] pub enum ChatSource { - /// Copies source history through a completed turn into the new chat. + #[serde(rename = "fork")] Fork(ForkChatSource), - /// Supplies source context to a new side chat without copying it into the side chat's visible history. + #[serde(rename = "sideChat")] SideChat(SideChatSource), } -impl TryFrom for ChatSource { - type Error = String; - - fn try_from(value: serde_json::Value) -> Result { - let Some(object) = value.as_object() else { - return Err("ChatSource must be a JSON object".to_string()); - }; - - match object.get("kind").and_then(|field| field.as_str()) { - Some("sideChat") => serde_json::from_value(value) - .map(Self::SideChat) - .map_err(|error| error.to_string()), - Some(kind) => Err(format!("unknown kind: {kind}")), - None => serde_json::from_value(value) - .map(Self::Fork) - .map_err(|error| error.to_string()), - } - } -} - -impl From for serde_json::Value { - fn from(value: ChatSource) -> Self { - match value { - ChatSource::Fork(inner) => { - serde_json::to_value(inner).expect("serializing ChatSource::Fork") - } - ChatSource::SideChat(inner) => { - serde_json::to_value(inner).expect("serializing ChatSource::SideChat") - } - } - } -} - // ─── ReconnectResult Union ──────────────────────────────────────────── /// Result of the `reconnect` command. diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 3f98d5422..b063a7c5a 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -827,8 +827,8 @@ pub struct AgentCapabilities { #[serde(rename_all = "camelCase")] pub struct MultipleChatsCapability { /// The agent can fork a chat from a specific turn. When absent or `false`, - /// clients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`) - /// to `createChat`. + /// clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to + /// `createChat`. /// Forking always implies multi-chat support. #[serde(default, skip_serializing_if = "Option::is_none")] pub fork: Option, diff --git a/clients/rust/crates/ahp-types/tests/chat_source.rs b/clients/rust/crates/ahp-types/tests/chat_source.rs new file mode 100644 index 000000000..2c5defdb6 --- /dev/null +++ b/clients/rust/crates/ahp-types/tests/chat_source.rs @@ -0,0 +1,42 @@ +use ahp_types::commands::ChatSource; + +#[test] +fn chat_source_routes_by_kind() { + let fork = serde_json::from_str::( + r#"{"kind":"fork","chat":"ahp-chat:/main","turnId":"turn-12"}"#, + ) + .expect("decode fork source"); + let side_chat = serde_json::from_str::( + r#"{"kind":"sideChat","chat":"ahp-chat:/main","turnId":"turn-active"}"#, + ) + .expect("decode side chat source"); + + match fork { + ChatSource::Fork(value) => { + assert_eq!(value.chat, "ahp-chat:/main"); + assert_eq!(value.turn_id, "turn-12"); + } + other => panic!("expected fork variant, got {other:?}"), + } + + match side_chat { + ChatSource::SideChat(value) => { + assert_eq!(value.chat, "ahp-chat:/main"); + assert_eq!(value.turn_id, "turn-active"); + } + other => panic!("expected sideChat variant, got {other:?}"), + } +} + +#[test] +fn chat_source_rejects_missing_or_unknown_kind() { + for raw in [ + r#"{"chat":"ahp-chat:/main","turnId":"turn-12"}"#, + r#"{"kind":"future","chat":"ahp-chat:/main","turnId":"turn-12"}"#, + ] { + assert!( + serde_json::from_str::(raw).is_err(), + "expected decode failure for {raw}" + ); + } +} diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index f6dca5acb..5e71dab8e 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -455,19 +455,21 @@ public struct DisposeSessionParams: Codable, Sendable { } public struct ForkChatSource: Codable, Sendable { + /// Discriminant + public var kind: ChatSourceKind /// URI of the existing source chat. public var chat: String /// Completed turn identifier in the source chat. /// /// Content through this turn is copied into the new chat's visible `turns`. - /// This preserves the existing 0.7.x flat fork wire format (`chat` + - /// `turnId`). public var turnId: String public init( + kind: ChatSourceKind, chat: String, turnId: String ) { + self.kind = kind self.chat = chat self.turnId = turnId } @@ -508,23 +510,21 @@ public struct CreateChatParams: Codable, Sendable { public var initialMessage: Message? /// Optional source chat and source turn. /// - /// The source chat MUST belong to this session. Clients MUST only request the - /// flat fork shape (`chat` + `turnId`) when the selected agent advertises - /// `capabilities.multipleChats.fork`, and - /// `kind: "sideChat"` when the selected agent advertises - /// `capabilities.multipleChats.sideChat`. Forks keep the legacy flat - /// `chat` + `turnId` shape and therefore only target completed turns. Side - /// chats also carry a stable `turnId`, which the host resolves against the - /// source chat's current active turn or retained history. If it resolves to - /// the active turn, the host snapshots the currently available partial - /// response when accepting `createChat`. + /// The source chat MUST belong to this session. Clients MUST only request + /// `kind: "fork"` when the selected agent advertises + /// `capabilities.multipleChats.fork`, and `kind: "sideChat"` when the + /// selected agent advertises `capabilities.multipleChats.sideChat`. Both + /// source forms carry a stable top-level `turnId`. Forks target completed + /// turns. Side chats also carry a stable `turnId`, which the host resolves + /// against the source chat's current active turn or retained history. If it + /// resolves to the active turn, the host snapshots the currently available + /// partial response when accepting `createChat`. public var source: ChatSource? /// Initial working-directory subset for this chat. Every entry MUST be /// present in the owning session's `workingDirectories`; the server MUST /// reject any entry that is not. When absent, the chat inherits the full - /// session set. Forked chats (those whose `source` uses the flat `chat` + - /// `turnId` shape) inherit the source chat's `workingDirectories`; this field - /// is ignored for forks. + /// session set. Forked chats (those whose `source.kind` is `"fork"`) inherit + /// the source chat's `workingDirectories`; this field is ignored for forks. /// /// A client MUST NOT supply this field unless the agent advertises /// {@link AgentCapabilities.multipleWorkingDirectories}. @@ -537,8 +537,7 @@ public struct CreateChatParams: Codable, Sendable { /// reject creation that omits it, or fall back to the first of the chat's /// directories. Fixed at creation and reported (read-only) on /// {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose - /// `source` uses the flat `chat` + `turnId` shape inherits the source chat's - /// primary). + /// `source.kind` is `"fork"` inherits the source chat's primary). public var primaryWorkingDirectory: String? public init( @@ -1470,17 +1469,20 @@ public enum ChatSource: Codable, Sendable { case fork(ForkChatSource) case sideChat(SideChatSource) - private enum DiscriminatorCodingKeys: String, CodingKey { case kind } + private enum DiscriminantKey: String, CodingKey { + case discriminant = "kind" + } public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: DiscriminatorCodingKeys.self) - switch try container.decodeIfPresent(String.self, forKey: .kind) { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + let discriminant = try container.decode(String.self, forKey: .discriminant) + switch discriminant { + case "fork": + self = .fork(try ForkChatSource(from: decoder)) case "sideChat": self = .sideChat(try SideChatSource(from: decoder)) - case nil: - self = .fork(try ForkChatSource(from: decoder)) - case .some(let discriminant): - throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown ChatSource discriminant: \(discriminant)") + default: + throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown ChatSource discriminant: \(discriminant)") } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 35833e234..ae060a043 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -658,8 +658,8 @@ public struct AgentCapabilities: Codable, Sendable { public struct MultipleChatsCapability: Codable, Sendable { /// The agent can fork a chat from a specific turn. When absent or `false`, - /// clients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`) - /// to `createChat`. + /// clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to + /// `createChat`. /// Forking always implies multi-chat support. public var fork: Bool? /// The agent can create a side chat from a specific turn. When absent or diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ChatSourceTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ChatSourceTests.swift new file mode 100644 index 000000000..330de5441 --- /dev/null +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ChatSourceTests.swift @@ -0,0 +1,51 @@ +import XCTest +import AgentHostProtocol + +final class ChatSourceTests: XCTestCase { + + func testChatSourceRoutesByKind() throws { + let decoder = JSONDecoder() + + let fork = try decoder.decode( + ChatSource.self, + from: Data(#"{"kind":"fork","chat":"ahp-chat:/main","turnId":"turn-12"}"#.utf8) + ) + let sideChat = try decoder.decode( + ChatSource.self, + from: Data(#"{"kind":"sideChat","chat":"ahp-chat:/main","turnId":"turn-active"}"#.utf8) + ) + + switch fork { + case .fork(let value): + XCTAssertEqual(value.kind, .fork) + XCTAssertEqual(value.turnId, "turn-12") + default: + XCTFail("Expected fork variant") + } + + switch sideChat { + case .sideChat(let value): + XCTAssertEqual(value.kind, .sideChat) + XCTAssertEqual(value.turnId, "turn-active") + default: + XCTFail("Expected sideChat variant") + } + } + + func testChatSourceRejectsMissingOrUnknownKind() { + let decoder = JSONDecoder() + + XCTAssertThrowsError( + try decoder.decode( + ChatSource.self, + from: Data(#"{"chat":"ahp-chat:/main","turnId":"turn-12"}"#.utf8) + ) + ) + XCTAssertThrowsError( + try decoder.decode( + ChatSource.self, + from: Data(#"{"kind":"future","chat":"ahp-chat:/main","turnId":"turn-12"}"#.utf8) + ) + ) + } +} diff --git a/docs/.changes/20260722-chat-discriminated-fork-source.json b/docs/.changes/20260722-chat-discriminated-fork-source.json new file mode 100644 index 000000000..d6dc81870 --- /dev/null +++ b/docs/.changes/20260722-chat-discriminated-fork-source.json @@ -0,0 +1,4 @@ +{ + "type": "fixed", + "message": "`createChat.source` is once again a fully discriminated union: fork and side-chat sources both require a `kind`, and missing or unknown kinds are rejected." +} diff --git a/docs/.changes/20260722-chat-legacy-fork-source.json b/docs/.changes/20260722-chat-legacy-fork-source.json deleted file mode 100644 index 917f9d071..000000000 --- a/docs/.changes/20260722-chat-legacy-fork-source.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "fixed", - "message": "`createChat.source` once again accepts the legacy flat fork payload without a `kind` property." -} diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index 71566b14d..843fd7319 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -163,9 +163,9 @@ ChatState { ``` Fork and side-chat creation both reference source turns by stable identifiers. -The legacy fork payload remains flat `{ chat, turnId }`; a side chat uses -`{ kind: 'sideChat', chat, turnId }`. For side chats, hosts resolve `turnId` -against either `activeTurn` or historical `turns` at creation time. If it names +Both source forms are fully discriminated — `{ kind: 'fork', chat, turnId }` +and `{ kind: 'sideChat', chat, turnId }`. For side chats, hosts resolve +`turnId` against either `activeTurn` or historical `turns` at creation time. If it names the active turn, the host snapshots the currently available response there, which preserves `/btw`-style side chats even though that same turn later moves into `turns` when it completes. @@ -667,9 +667,9 @@ createChat({ }); ``` -Forked chats (those whose `source` uses the flat `{ chat, turnId }` shape) -inherit the source chat's `workingDirectories` and primary, so both fields are -ignored for forks. Side chats can still choose their own subset and primary, +Forked chats (those whose `source.kind` is `"fork"`) inherit the source +chat's `workingDirectories` and primary, so both fields are ignored for forks. +Side chats can still choose their own subset and primary, and they still reference their source with a stable `turnId` whether that turn was active or historical when the side chat was created. diff --git a/docs/proposals/multi-chat.md b/docs/proposals/multi-chat.md index cfc547bb8..81a19cad7 100644 --- a/docs/proposals/multi-chat.md +++ b/docs/proposals/multi-chat.md @@ -430,12 +430,13 @@ a `copilot` per piece of work; `/resume` reopens one at a time): A new chat is forked from a point in an existing chat, seeded with that history, then diverges on its own. Both chats keep sharing the session's context. -A **side chat** starts from a stable `(chat, turnId)` reference. The host -resolves that id against either a completed turn or the parent's current active -turn at creation time, but the wire does not snapshot which lifecycle slot held -it. If the id names the active turn, the host snapshots whatever response is -available at that moment. This preserves `/btw`-style side questions without -copying the parent's turns +A **fork** uses `{ kind: "fork", chat, turnId }` to copy visible history +through a completed source turn into a new chat. A **side chat** uses +`{ kind: "sideChat", chat, turnId }`. The host resolves that stable `turnId` +against either a completed turn or the parent's current active turn at creation +time, but the wire does not snapshot which lifecycle slot held it. If the id +names the active turn, the host snapshots whatever response is available at that +moment. This preserves `/btw`-style side questions without copying the parent's turns into its own transcript. It is a focused, independent conversation that can later be pulled back into the main chat as a bounded chat attachment. The distinction keeps the side transcript clean while diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 743653c70..8d5bd048c 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -62,23 +62,22 @@ Clients MAY periodically sync their local input state into the draft by dispatch [`createChat`](/reference/chat#createchat) is a JSON-RPC request. Callers identify the owning session via the request's `channel` parameter (`ahp-session:/`) and MAY supply: - an `initialMessage` to start the first turn immediately — carrying its own [`model`](/reference/chat#message) / [`agent`](/reference/chat#message) selection — and -- a `source` of type [`ChatSource`](/reference/chat#chatsource), which is - either the legacy flat fork payload `{ chat, turnId }` or a side-chat payload - `{ kind: "sideChat", chat, turnId }` selecting a specific source turn. +- a `source` of type [`ChatSource`](/reference/chat#chatsource), either + `{ kind: "fork", chat, turnId }` or `{ kind: "sideChat", chat, turnId }`, + selecting a specific source turn. The server allocates the chat URI and adds the chat to the session's catalog (`session/chatAdded` on the session channel) before returning. Clients MUST gate source-based creation using the selected [`AgentInfo.capabilities.multipleChats`](/reference/root#multiplechatscapability): -- `fork: true` permits the legacy flat fork shape `{ chat, turnId }`. +- `fork: true` permits `source.kind: "fork"`. - `sideChat: true` permits `source.kind: "sideChat"`. Absence or `false` means the corresponding source form is unsupported. The host MUST reject an unsupported source. It MUST also reject a source chat outside the target session, an unknown source chat or turn, or a source that names the chat -being created. For forks, the host MUST additionally reject any source that does -not use the legacy flat `chat` + `turnId` fork shape — forks only target -completed turns. +being created. For forks, the host MUST additionally reject any source whose +`kind` is not `"fork"` — forks only target completed turns. For side chats, `turnId` is a stable identity, not a lifecycle snapshot. Hosts and clients resolve it against the source chat's current `activeTurn` or its diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 87d04f9bc..3ee33a3cc 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -2737,7 +2737,7 @@ "properties": { "fork": { "type": "boolean", - "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`)\nto `createChat`.\nForking always implies multi-chat support." + "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatSource} with `kind: \"fork\"` to\n`createChat`.\nForking always implies multi-chat support." }, "sideChat": { "type": "boolean", @@ -7307,7 +7307,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins likewise carry a stable `turnId` identity alongside\n`kind: \"sideChat\"` instead of snapshotting whether that turn was active or\nhistorical at creation time. Consumers resolve the identifier against the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork and side-chat origins both carry a stable top-level `turnId` alongside\ntheir discriminated `kind` value instead of snapshotting whether that turn\nwas active or historical at creation time. Consumers resolve the identifier\nagainst the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInputQuestion": { "oneOf": [ diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 6a7267504..ad54eba94 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -1104,16 +1104,21 @@ "type": "object", "description": "Copies source history through a completed turn into the new chat.", "properties": { + "kind": { + "const": "fork", + "description": "Discriminant" + }, "chat": { "$ref": "#/$defs/URI", "description": "URI of the existing source chat." }, "turnId": { "type": "string", - "description": "Completed turn identifier in the source chat.\n\nContent through this turn is copied into the new chat's visible `turns`.\nThis preserves the existing 0.7.x flat fork wire format (`chat` +\n`turnId`)." + "description": "Completed turn identifier in the source chat.\n\nContent through this turn is copied into the new chat's visible `turns`." } }, "required": [ + "kind", "chat", "turnId" ] @@ -1159,18 +1164,18 @@ }, "source": { "$ref": "#/$defs/ChatSource", - "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request the\nflat fork shape (`chat` + `turnId`) when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks keep the legacy flat\n`chat` + `turnId` shape and therefore only target completed turns. Side\nchats also carry a stable `turnId`, which the host resolves against the\nsource chat's current active turn or retained history. If it resolves to\nthe active turn, the host snapshots the currently available partial\nresponse when accepting `createChat`." + "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and `kind: \"sideChat\"` when the\nselected agent advertises `capabilities.multipleChats.sideChat`. Both\nsource forms carry a stable top-level `turnId`. Forks target completed\nturns. Side chats also carry a stable `turnId`, which the host resolves\nagainst the source chat's current active turn or retained history. If it\nresolves to the active turn, the host snapshots the currently available\npartial response when accepting `createChat`." }, "workingDirectories": { "type": "array", "items": { "$ref": "#/$defs/URI" }, - "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (those whose `source` uses the flat `chat` +\n`turnId` shape) inherit the source chat's `workingDirectories`; this field\nis ignored for forks.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." + "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (those whose `source.kind` is `\"fork\"`) inherit\nthe source chat's `workingDirectories`; this field is ignored for forks.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." }, "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on. When set, it MUST be one of the chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nomitted). A client SHOULD supply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first of the chat's\ndirectories. Fixed at creation and reported (read-only) on\n{@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose\n`source` uses the flat `chat` + `turnId` shape inherits the source chat's\nprimary)." + "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on. When set, it MUST be one of the chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nomitted). A client SHOULD supply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first of the chat's\ndirectories. Fixed at creation and reported (read-only) on\n{@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose\n`source.kind` is `\"fork\"` inherits the source chat's primary)." } }, "required": [ @@ -1903,7 +1908,7 @@ "properties": { "fork": { "type": "boolean", - "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`)\nto `createChat`.\nForking always implies multi-chat support." + "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatSource} with `kind: \"fork\"` to\n`createChat`.\nForking always implies multi-chat support." }, "sideChat": { "type": "boolean", @@ -8656,18 +8661,7 @@ "ChatSource": { "oneOf": [ { - "allOf": [ - { - "$ref": "#/$defs/ForkChatSource" - }, - { - "not": { - "required": [ - "kind" - ] - } - } - ] + "$ref": "#/$defs/ForkChatSource" }, { "$ref": "#/$defs/SideChatSource" @@ -9036,7 +9030,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins likewise carry a stable `turnId` identity alongside\n`kind: \"sideChat\"` instead of snapshotting whether that turn was active or\nhistorical at creation time. Consumers resolve the identifier against the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork and side-chat origins both carry a stable top-level `turnId` alongside\ntheir discriminated `kind` value instead of snapshotting whether that turn\nwas active or historical at creation time. Consumers resolve the identifier\nagainst the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInteractivity": { "enum": [ diff --git a/schema/errors.schema.json b/schema/errors.schema.json index f77dbb36e..2f9d24776 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -640,7 +640,7 @@ "properties": { "fork": { "type": "boolean", - "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`)\nto `createChat`.\nForking always implies multi-chat support." + "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatSource} with `kind: \"fork\"` to\n`createChat`.\nForking always implies multi-chat support." }, "sideChat": { "type": "boolean", @@ -6088,16 +6088,21 @@ "type": "object", "description": "Copies source history through a completed turn into the new chat.", "properties": { + "kind": { + "const": "fork", + "description": "Discriminant" + }, "chat": { "$ref": "#/$defs/URI", "description": "URI of the existing source chat." }, "turnId": { "type": "string", - "description": "Completed turn identifier in the source chat.\n\nContent through this turn is copied into the new chat's visible `turns`.\nThis preserves the existing 0.7.x flat fork wire format (`chat` +\n`turnId`)." + "description": "Completed turn identifier in the source chat.\n\nContent through this turn is copied into the new chat's visible `turns`." } }, "required": [ + "kind", "chat", "turnId" ] @@ -6143,18 +6148,18 @@ }, "source": { "$ref": "#/$defs/ChatSource", - "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request the\nflat fork shape (`chat` + `turnId`) when the selected agent advertises\n`capabilities.multipleChats.fork`, and\n`kind: \"sideChat\"` when the selected agent advertises\n`capabilities.multipleChats.sideChat`. Forks keep the legacy flat\n`chat` + `turnId` shape and therefore only target completed turns. Side\nchats also carry a stable `turnId`, which the host resolves against the\nsource chat's current active turn or retained history. If it resolves to\nthe active turn, the host snapshots the currently available partial\nresponse when accepting `createChat`." + "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and `kind: \"sideChat\"` when the\nselected agent advertises `capabilities.multipleChats.sideChat`. Both\nsource forms carry a stable top-level `turnId`. Forks target completed\nturns. Side chats also carry a stable `turnId`, which the host resolves\nagainst the source chat's current active turn or retained history. If it\nresolves to the active turn, the host snapshots the currently available\npartial response when accepting `createChat`." }, "workingDirectories": { "type": "array", "items": { "$ref": "#/$defs/URI" }, - "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (those whose `source` uses the flat `chat` +\n`turnId` shape) inherit the source chat's `workingDirectories`; this field\nis ignored for forks.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." + "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (those whose `source.kind` is `\"fork\"`) inherit\nthe source chat's `workingDirectories`; this field is ignored for forks.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." }, "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on. When set, it MUST be one of the chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nomitted). A client SHOULD supply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first of the chat's\ndirectories. Fixed at creation and reported (read-only) on\n{@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose\n`source` uses the flat `chat` + `turnId` shape inherits the source chat's\nprimary)." + "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on. When set, it MUST be one of the chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nomitted). A client SHOULD supply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first of the chat's\ndirectories. Fixed at creation and reported (read-only) on\n{@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose\n`source.kind` is `\"fork\"` inherits the source chat's primary)." } }, "required": [ @@ -6623,7 +6628,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins likewise carry a stable `turnId` identity alongside\n`kind: \"sideChat\"` instead of snapshotting whether that turn was active or\nhistorical at creation time. Consumers resolve the identifier against the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork and side-chat origins both carry a stable top-level `turnId` alongside\ntheir discriminated `kind` value instead of snapshotting whether that turn\nwas active or historical at creation time. Consumers resolve the identifier\nagainst the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInteractivity": { "enum": [ @@ -7235,18 +7240,7 @@ "ChatSource": { "oneOf": [ { - "allOf": [ - { - "$ref": "#/$defs/ForkChatSource" - }, - { - "not": { - "required": [ - "kind" - ] - } - } - ] + "$ref": "#/$defs/ForkChatSource" }, { "$ref": "#/$defs/SideChatSource" diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 0eec127d5..a85edffd1 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -803,7 +803,7 @@ "properties": { "fork": { "type": "boolean", - "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`)\nto `createChat`.\nForking always implies multi-chat support." + "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatSource} with `kind: \"fork\"` to\n`createChat`.\nForking always implies multi-chat support." }, "sideChat": { "type": "boolean", @@ -5442,7 +5442,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins likewise carry a stable `turnId` identity alongside\n`kind: \"sideChat\"` instead of snapshotting whether that turn was active or\nhistorical at creation time. Consumers resolve the identifier against the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork and side-chat origins both carry a stable top-level `turnId` alongside\ntheir discriminated `kind` value instead of snapshotting whether that turn\nwas active or historical at creation time. Consumers resolve the identifier\nagainst the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInteractivity": { "enum": [ diff --git a/schema/state.schema.json b/schema/state.schema.json index 3e22a4065..e2685fe13 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -551,7 +551,7 @@ "properties": { "fork": { "type": "boolean", - "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`)\nto `createChat`.\nForking always implies multi-chat support." + "description": "The agent can fork a chat from a specific turn. When absent or `false`,\nclients MUST NOT pass a {@link ChatSource} with `kind: \"fork\"` to\n`createChat`.\nForking always implies multi-chat support." }, "sideChat": { "type": "boolean", @@ -5121,7 +5121,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork origins preserve the existing flat `chat` + `turnId` wire shape. Side\nchat origins likewise carry a stable `turnId` identity alongside\n`kind: \"sideChat\"` instead of snapshotting whether that turn was active or\nhistorical at creation time. Consumers resolve the identifier against the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork and side-chat origins both carry a stable top-level `turnId` alongside\ntheir discriminated `kind` value instead of snapshotting whether that turn\nwas active or historical at creation time. Consumers resolve the identifier\nagainst the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInputQuestion": { "oneOf": [ diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 7b19e3096..6b83657d5 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -592,10 +592,19 @@ function generateDiscriminatedUnion(cfg: UnionConfig): string { // UnmarshalJSON lines.push(`// UnmarshalJSON decodes the variant indicated by the ${JSON.stringify(cfg.discriminantField)} discriminator.`); lines.push(`func (u *${cfg.name}) UnmarshalJSON(data []byte) error {`); - lines.push(`\tdisc, _, err := readDiscriminator(data, ${JSON.stringify(cfg.discriminantField)})`); + lines.push( + `\tdisc, ${cfg.unknown ? '_' : 'ok'}, err := readDiscriminator(data, ${JSON.stringify(cfg.discriminantField)})`, + ); lines.push('\tif err != nil {'); lines.push('\t\treturn err'); lines.push('\t}'); + if (!cfg.unknown) { + lines.push('\tif !ok {'); + lines.push( + `\t\treturn missingDiscriminatorError(${JSON.stringify(cfg.name)}, ${JSON.stringify(cfg.discriminantField)})`, + ); + lines.push('\t}'); + } lines.push('\tswitch disc {'); for (const v of cfg.variants) { lines.push(`\tcase ${JSON.stringify(v.wireValue)}:`); @@ -611,7 +620,9 @@ function generateDiscriminatedUnion(cfg: UnionConfig): string { lines.push('\t\tcopy(raw, data)'); lines.push(`\t\tu.Value = &${cfg.name}Unknown{Raw: raw}`); } else { - lines.push(`\t\treturn &json.UnmarshalTypeError{Value: "${cfg.name}", Type: nil}`); + lines.push( + `\t\treturn unknownDiscriminatorError(${JSON.stringify(cfg.name)}, ${JSON.stringify(cfg.discriminantField)}, disc)`, + ); } lines.push('\t}'); lines.push('\treturn nil'); @@ -1516,6 +1527,16 @@ const RECONNECT_RESULT_UNION: UnionConfig = { ], }; +const CHAT_SOURCE_UNION: UnionConfig = { + name: 'ChatSource', + discriminantField: 'kind', + doc: 'ChatSource identifies how a new chat uses a source chat.', + variants: [ + { variantName: 'Fork', innerType: 'ForkChatSource', wireValue: 'fork' }, + { variantName: 'SideChat', innerType: 'SideChatSource', wireValue: 'sideChat' }, + ], +}; + function generateChangesetOperationTargetGo(): string { return `// ChangesetOperationTarget identifies the file or range a // ChangesetOperation should act on. @@ -1579,55 +1600,6 @@ func (t ChangesetOperationTarget) MarshalJSON() ([]byte, error) { }`; } -function generateChatSourceGo(): string { - return `// ChatSource identifies how a new chat uses a source chat. -type ChatSource struct { - Value isChatSource -} - -// isChatSource is the marker interface for chat source variants. -type isChatSource interface{ isChatSource() } - -func (*ForkChatSource) isChatSource() {} -func (*SideChatSource) isChatSource() {} - -// UnmarshalJSON decodes side-chat sources by \`kind: "sideChat"\`. Any other -// object without a \`kind\` is treated as the legacy flat fork payload. -func (s *ChatSource) UnmarshalJSON(data []byte) error { - disc, ok, err := readDiscriminator(data, "kind") - if err != nil { - return err - } - if ok { - switch disc { - case "sideChat": - var value SideChatSource - if err := json.Unmarshal(data, &value); err != nil { - return err - } - s.Value = &value - return nil - default: - return &json.UnmarshalTypeError{Value: "ChatSource kind " + disc, Type: nil} - } - } - var value ForkChatSource - if err := json.Unmarshal(data, &value); err != nil { - return err - } - s.Value = &value - return nil -} - -// MarshalJSON encodes the active variant back to JSON. -func (s ChatSource) MarshalJSON() ([]byte, error) { - if s.Value == nil { - return []byte("null"), nil - } - return json.Marshal(s.Value) -}`; -} - function generateCommandsFile(project: Project): string { const lines: string[] = [HEADER_WITH_IMPORTS]; @@ -1659,7 +1631,7 @@ function generateCommandsFile(project: Project): string { } lines.push('// ─── ChatSource Union ─────────────────────────────────────────────────\n'); - lines.push(generateChatSourceGo()); + lines.push(generateDiscriminatedUnion(CHAT_SOURCE_UNION)); lines.push(''); lines.push('// ─── ReconnectResult Union ────────────────────────────────────────────\n'); diff --git a/scripts/generate-json-schema.test.ts b/scripts/generate-json-schema.test.ts index 8852c3f83..6fdf5d0ae 100644 --- a/scripts/generate-json-schema.test.ts +++ b/scripts/generate-json-schema.test.ts @@ -183,7 +183,7 @@ describe('generated JSON schemas', () => { assert.deepEqual(kinds, ['user', 'fork', 'sideChat', 'tool']); }); - it('preserves flat fork sources and stable side-chat turn identifiers', () => { + it('requires stable top-level turn identifiers for fork and side-chat provenance', () => { const defs = schema.$defs as Record>; const chatOrigin = defs.ChatOrigin; const branches = chatOrigin.oneOf as Array>; @@ -194,41 +194,20 @@ describe('generated JSON schemas', () => { }), ); - assert.deepEqual( - branchByKind.get('fork')?.turnId?.type, - 'string', - ); - assert.deepEqual( - branchByKind.get('sideChat')?.turnId?.type, - 'string', - ); + assert.deepEqual(branchByKind.get('fork')?.turnId?.type, 'string'); + assert.deepEqual(branchByKind.get('sideChat')?.turnId?.type, 'string'); const chatSource = defs.ChatSource; if (chatSource) { - assert.deepEqual( - defs.ForkChatSource?.properties?.turnId?.type, - 'string', - ); - assert.equal( - defs.ForkChatSource?.properties?.kind, - undefined, - ); - assert.deepEqual( - defs.SideChatSource?.properties?.turnId?.type, - 'string', - ); - assert.deepEqual( - defs.SideChatSource?.properties?.kind?.const, - 'sideChat', - ); - assert.equal( - defs.SideChatSource?.properties?.turn, - undefined, - ); + assert.deepEqual(defs.ForkChatSource?.properties?.kind?.const, 'fork'); + assert.deepEqual(defs.ForkChatSource?.properties?.turnId?.type, 'string'); + assert.deepEqual(defs.SideChatSource?.properties?.kind?.const, 'sideChat'); + assert.deepEqual(defs.SideChatSource?.properties?.turnId?.type, 'string'); + assert.equal(defs.SideChatSource?.properties?.turn, undefined); } }); - it('validates ChatSource kind disambiguation', () => { + it('accepts valid discriminated ChatSource payloads and rejects missing or unknown kinds', () => { const defs = schema.$defs as Record>; const chatSource = defs.ChatSource; if (!chatSource) { @@ -237,7 +216,7 @@ describe('generated JSON schemas', () => { assert.equal( schemaAccepts(schema, chatSource, { - kind: 'sideChat', + kind: 'fork', chat: 'ahp-chat:/source', turnId: 'turn-1', }), @@ -245,6 +224,7 @@ describe('generated JSON schemas', () => { ); assert.equal( schemaAccepts(schema, chatSource, { + kind: 'sideChat', chat: 'ahp-chat:/source', turnId: 'turn-1', }), @@ -252,7 +232,14 @@ describe('generated JSON schemas', () => { ); assert.equal( schemaAccepts(schema, chatSource, { - kind: 'fork', + chat: 'ahp-chat:/source', + turnId: 'turn-1', + }), + false, + ); + assert.equal( + schemaAccepts(schema, chatSource, { + kind: 'unknown', chat: 'ahp-chat:/source', turnId: 'turn-1', }), diff --git a/scripts/generate-json-schema.ts b/scripts/generate-json-schema.ts index 13a35abac..217115a17 100644 --- a/scripts/generate-json-schema.ts +++ b/scripts/generate-json-schema.ts @@ -126,97 +126,6 @@ function getAllInterfaceProperties(iface: InterfaceDeclaration, project: Project return [...byName.values()]; } -function resolveSchemaForInspection(schema: JsonSchema, project: Project): JsonSchema | undefined { - if (!schema.$ref) return schema; - const match = schema.$ref.match(/^#\/\$defs\/(.+)$/); - if (!match) return undefined; - return buildDefForName(project, match[1]); -} - -function getObjectSchemaMetadata( - schema: JsonSchema, - project: Project, -): { properties: Record; required: Set } | undefined { - const resolved = resolveSchemaForInspection(schema, project); - if (!resolved || resolved.type !== 'object' || !resolved.properties) return undefined; - return { - properties: resolved.properties, - required: new Set(resolved.required ?? []), - }; -} - -function makeUnionBranchesMutuallyExclusive( - branches: JsonSchema[], - project: Project, -): JsonSchema[] { - const metadata = branches.map(branch => getObjectSchemaMetadata(branch, project)); - const objectMetadata = metadata.filter( - (info): info is { properties: Record; required: Set } => !!info, - ); - if (objectMetadata.length !== metadata.length) { - return branches; - } - - const candidateProperties = new Set(); - for (const info of objectMetadata) { - for (const [name, schema] of Object.entries(info.properties)) { - if (info.required.has(name) && schema.const !== undefined) { - candidateProperties.add(name); - } - } - } - - for (const propertyName of candidateProperties) { - let hasLegacyBranch = false; - let hasDiscriminatedBranch = false; - const discriminantValues = new Set(); - let supported = true; - - for (const info of objectMetadata) { - const propertySchema = info.properties[propertyName]; - if (!propertySchema) { - hasLegacyBranch = true; - continue; - } - - if (!info.required.has(propertyName) || propertySchema.const === undefined) { - supported = false; - break; - } - - if (discriminantValues.has(propertySchema.const)) { - supported = false; - break; - } - - discriminantValues.add(propertySchema.const); - hasDiscriminatedBranch = true; - } - - if (!supported || !hasLegacyBranch || !hasDiscriminatedBranch) { - continue; - } - - return branches.map((branch, index) => { - if (objectMetadata[index].properties[propertyName]) { - return branch; - } - return { - allOf: [ - branch, - { - not: { - required: [propertyName], - }, - }, - ], - }; - }); - } - - return branches; -} - // ─── Type → JSON Schema Conversion ────────────────────────────────────────── function typeTextToSchema(typeText: string, project: Project, _depth = 0): JsonSchema { @@ -297,10 +206,7 @@ function typeTextToSchema(typeText: string, project: Project, _depth = 0): JsonS const parts = splitUnionType(cleaned).filter(p => p !== 'undefined' && p !== ''); if (parts.length > 1) { return { - oneOf: makeUnionBranchesMutuallyExclusive( - parts.map(p => typeTextToSchema(p, project, _depth + 1)), - project, - ), + oneOf: parts.map(p => typeTextToSchema(p, project, _depth + 1)), }; } if (parts.length === 1 && parts[0] !== cleaned) { diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index b3f664995..9b887f982 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -1480,6 +1480,15 @@ const RECONNECT_RESULT_UNION: UnionConfig = { ], }; +const CHAT_SOURCE_UNION: UnionConfig = { + name: 'ChatSource', + discriminantField: 'kind', + variants: [ + { caseName: 'Fork', structName: 'ForkChatSource', discriminantValue: 'fork' }, + { caseName: 'SideChat', structName: 'SideChatSource', discriminantValue: 'sideChat' }, + ], +}; + /** * ChangesetOperationTarget — TS discriminated union over `{ kind: "resource" }` * and `{ kind: "range" }`. The variant structs are inline-only in TS (not @@ -1552,53 +1561,6 @@ internal object ChangesetOperationTargetSerializer : KSerializer { - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("ChatSource") - - override fun deserialize(decoder: Decoder): ChatSource { - val input = decoder as? JsonDecoder - ?: error("ChatSource can only be deserialized from JSON") - val element = input.decodeJsonElement() - val obj = element as? JsonObject - ?: error("Expected JsonObject for ChatSource") - val kind = (obj["kind"] as? JsonPrimitive)?.contentOrNull - return when (kind) { - "sideChat" -> ChatSourceSideChat( - input.json.decodeFromJsonElement(SideChatSource.serializer(), element), - ) - null -> ChatSourceFork( - input.json.decodeFromJsonElement(ForkChatSource.serializer(), element), - ) - else -> error("Unknown ChatSource discriminator: $kind") - } - } - - override fun serialize(encoder: Encoder, value: ChatSource) { - val output = encoder as? JsonEncoder - ?: error("ChatSource can only be serialized to JSON") - val element: JsonElement = when (value) { - is ChatSourceFork -> - output.json.encodeToJsonElement(ForkChatSource.serializer(), value.value) - is ChatSourceSideChat -> - output.json.encodeToJsonElement(SideChatSource.serializer(), value.value) - } - output.encodeJsonElement(element) - } -}`; -} - function generateCommandsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; @@ -1629,7 +1591,7 @@ function generateCommandsFile(project: Project): string { lines.push('// ─── ChatSource Union ───────────────────────────────────────────────────────'); lines.push(''); - lines.push(generateChatSourceKotlin()); + lines.push(generateDiscriminatedUnion(CHAT_SOURCE_UNION)); lines.push(''); lines.push('// ─── ReconnectResult Union ──────────────────────────────────────────────────'); diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index d255cea00..47e5f23df 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -631,90 +631,6 @@ function generateDiscriminatedUnion(cfg: UnionConfig): string { return lines.join('\n'); } -function generateValueRoutedDiscriminatedUnion(cfg: UnionConfig): string { - const lines: string[] = []; - if (cfg.doc) { - for (const d of cfg.doc.split('\n')) lines.push(`/// ${d.trimEnd()}`); - } - lines.push('#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]'); - lines.push('#[serde(try_from = "serde_json::Value", into = "serde_json::Value")]'); - lines.push(`pub enum ${cfg.name} {`); - - for (const v of cfg.variants) { - if (v.doc) { - for (const d of v.doc.split('\n')) lines.push(` /// ${d.trimEnd()}`); - } - if (v.isUnit) { - lines.push(` ${v.variantName},`); - } else { - const inner = v.boxed ? `Box<${v.innerType}>` : v.innerType; - lines.push(` ${v.variantName}(${inner}),`); - } - } - - if (cfg.unknown) { - lines.push(' /// Unknown or future variant — preserved as raw JSON for round-trip fidelity.'); - lines.push(' /// Reducers treat this as a no-op.'); - lines.push(' Unknown(serde_json::Value),'); - } - - lines.push('}'); - lines.push(''); - lines.push(`impl TryFrom for ${cfg.name} {`); - lines.push(' type Error = String;'); - lines.push(''); - lines.push(' fn try_from(value: serde_json::Value) -> Result {'); - lines.push(' let Some(object) = value.as_object() else {'); - lines.push(` return Err("${cfg.name} must be a JSON object".to_string());`); - lines.push(' };'); - lines.push(` let Some(kind) = object.get(${JSON.stringify(cfg.discriminantField)}).and_then(|value| value.as_str()) else {`); - lines.push(` return Err("${cfg.name} is missing a string ${cfg.discriminantField} discriminant".to_string());`); - lines.push(' };'); - lines.push(''); - lines.push(' match kind {'); - for (const v of cfg.variants) { - lines.push(` ${JSON.stringify(v.wireValue)} => {`); - if (v.isUnit) { - lines.push(` Ok(Self::${v.variantName})`); - } else { - const boxOpen = v.boxed ? 'Box::new(' : ''; - const boxClose = v.boxed ? ')' : ''; - lines.push(` serde_json::from_value(value).map(|inner| Self::${v.variantName}(${boxOpen}inner${boxClose})).map_err(|error| error.to_string())`); - } - lines.push(' }'); - } - if (cfg.unknown) { - lines.push(' _ => Ok(Self::Unknown(value)),'); - } else { - lines.push(` _ => Err(format!("unknown ${cfg.discriminantField}: {kind}")),`); - } - lines.push(' }'); - lines.push(' }'); - lines.push('}'); - lines.push(''); - lines.push(`impl From<${cfg.name}> for serde_json::Value {`); - lines.push(` fn from(value: ${cfg.name}) -> Self {`); - lines.push(' match value {'); - for (const v of cfg.variants) { - if (v.isUnit) { - lines.push(` ${cfg.name}::${v.variantName} => serde_json::json!({ ${JSON.stringify(cfg.discriminantField)}: ${JSON.stringify(v.wireValue)} }),`); - } else { - const innerPattern = v.boxed ? 'inner' : 'inner'; - const innerValue = v.boxed ? '*inner' : 'inner'; - lines.push(` ${cfg.name}::${v.variantName}(${innerPattern}) => serde_json::to_value(${innerValue}).expect("serializing ${cfg.name}::${v.variantName}"),`); - } - } - if (cfg.unknown) { - lines.push(` ${cfg.name}::Unknown(value) => value,`); - } - lines.push(' }'); - lines.push(' }'); - lines.push('}'); - return lines.join('\n'); -} - -// ─── Interface → Rust Struct (auto) ────────────────────────────────────────── - function generateStructFromInterface( project: Project, tsInterfaceName: string, @@ -1507,7 +1423,7 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: s { name: 'SubscribeParams' }, { name: 'SubscribeView' }, { name: 'SubscriptionDeliveryOptions' }, { name: 'SubscribeResult' }, { name: 'SessionForkSource' }, { name: 'CreateSessionParams' }, { name: 'DisposeSessionParams' }, - { name: 'ForkChatSource' }, { name: 'SideChatSource' }, { name: 'CreateChatParams' }, + { name: 'ForkChatSource', omitDiscriminants: true }, { name: 'SideChatSource', omitDiscriminants: true }, { name: 'CreateChatParams' }, { name: 'DisposeChatParams' }, { name: 'ListSessionsParams' }, { name: 'ListSessionsResult' }, { name: 'ResourceReadParams' }, { name: 'ResourceReadResult' }, @@ -1543,6 +1459,16 @@ const RECONNECT_RESULT_UNION: UnionConfig = { ], }; +const CHAT_SOURCE_UNION: UnionConfig = { + name: 'ChatSource', + discriminantField: 'kind', + doc: 'How a new chat uses a source chat.', + variants: [ + { variantName: 'Fork', innerType: 'ForkChatSource', wireValue: 'fork' }, + { variantName: 'SideChat', innerType: 'SideChatSource', wireValue: 'sideChat' }, + ], +}; + function generateCommandsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); @@ -1581,7 +1507,7 @@ function generateCommandsFile(project: Project): string { } lines.push('// ─── ChatSource Union ─────────────────────────────────────────────────\n'); - lines.push(generateChatSource()); + lines.push(generateDiscriminatedUnion(CHAT_SOURCE_UNION)); lines.push(''); lines.push('// ─── ReconnectResult Union ────────────────────────────────────────────\n'); @@ -1595,47 +1521,6 @@ function generateCommandsFile(project: Project): string { return lines.join('\n'); } -function generateChatSource(): string { - return `/// How a new chat uses a source chat. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(try_from = "serde_json::Value", into = "serde_json::Value")] -pub enum ChatSource { - /// Copies source history through a completed turn into the new chat. - Fork(ForkChatSource), - /// Supplies source context to a new side chat without copying it into the side chat's visible history. - SideChat(SideChatSource), -} - -impl TryFrom for ChatSource { - type Error = String; - - fn try_from(value: serde_json::Value) -> Result { - let Some(object) = value.as_object() else { - return Err("ChatSource must be a JSON object".to_string()); - }; - - match object.get("kind").and_then(|field| field.as_str()) { - Some("sideChat") => serde_json::from_value(value) - .map(Self::SideChat) - .map_err(|error| error.to_string()), - Some(kind) => Err(format!("unknown kind: {kind}")), - None => serde_json::from_value(value) - .map(Self::Fork) - .map_err(|error| error.to_string()), - } - } -} - -impl From for serde_json::Value { - fn from(value: ChatSource) -> Self { - match value { - ChatSource::Fork(inner) => serde_json::to_value(inner).expect("serializing ChatSource::Fork"), - ChatSource::SideChat(inner) => serde_json::to_value(inner).expect("serializing ChatSource::SideChat"), - } - } -}`; -} - function generateSubscribeParamsImplRust(): string { return `impl SubscribeParams { /// Create subscribe params with default delivery behavior. diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index bc6d08d62..98514674b 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -1403,6 +1403,15 @@ const RECONNECT_RESULT_UNION: UnionConfig = { ], }; +const CHAT_SOURCE_UNION: UnionConfig = { + name: 'ChatSource', + discriminantField: 'kind', + variants: [ + { caseName: 'fork', structName: 'ForkChatSource', discriminantValue: 'fork' }, + { caseName: 'sideChat', structName: 'SideChatSource', discriminantValue: 'sideChat' }, + ], +}; + function generateCommandsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; @@ -1431,7 +1440,7 @@ function generateCommandsFile(project: Project): string { } lines.push('// MARK: - Command Unions\n'); - lines.push(generateChatSourceSwift()); + lines.push(generateDiscriminatedUnion(CHAT_SOURCE_UNION)); lines.push(''); lines.push('// MARK: - ReconnectResult Union\n'); @@ -1445,34 +1454,6 @@ function generateCommandsFile(project: Project): string { return lines.join('\n'); } -function generateChatSourceSwift(): string { - return `public enum ChatSource: Codable, Sendable { - case fork(ForkChatSource) - case sideChat(SideChatSource) - - private enum DiscriminatorCodingKeys: String, CodingKey { case kind } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: DiscriminatorCodingKeys.self) - switch try container.decodeIfPresent(String.self, forKey: .kind) { - case "sideChat": - self = .sideChat(try SideChatSource(from: decoder)) - case nil: - self = .fork(try ForkChatSource(from: decoder)) - case .some(let discriminant): - throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown ChatSource discriminant: \\(discriminant)") - } - } - - public func encode(to encoder: Encoder) throws { - switch self { - case .fork(let value): try value.encode(to: encoder) - case .sideChat(let value): try value.encode(to: encoder) - } - } -}`; -} - function generateChangesetOperationTargetSwift(): string { return `/// Identifies the file or range a \`ChangesetOperation\` should act on. public enum ChangesetOperationTarget: Codable, Sendable { diff --git a/types/channels-chat/commands.ts b/types/channels-chat/commands.ts index 3208482dc..325a167a3 100644 --- a/types/channels-chat/commands.ts +++ b/types/channels-chat/commands.ts @@ -24,14 +24,14 @@ export const enum ChatSourceKind { * Copies source history through a completed turn into the new chat. */ export interface ForkChatSource { + /** Discriminant */ + kind: ChatSourceKind.Fork; /** URI of the existing source chat. */ chat: URI; /** * Completed turn identifier in the source chat. * * Content through this turn is copied into the new chat's visible `turns`. - * This preserves the existing 0.7.x flat fork wire format (`chat` + - * `turnId`). */ turnId: string; } @@ -84,25 +84,23 @@ export interface CreateChatParams extends BaseParams { /** * Optional source chat and source turn. * - * The source chat MUST belong to this session. Clients MUST only request the - * flat fork shape (`chat` + `turnId`) when the selected agent advertises - * `capabilities.multipleChats.fork`, and - * `kind: "sideChat"` when the selected agent advertises - * `capabilities.multipleChats.sideChat`. Forks keep the legacy flat - * `chat` + `turnId` shape and therefore only target completed turns. Side - * chats also carry a stable `turnId`, which the host resolves against the - * source chat's current active turn or retained history. If it resolves to - * the active turn, the host snapshots the currently available partial - * response when accepting `createChat`. + * The source chat MUST belong to this session. Clients MUST only request + * `kind: "fork"` when the selected agent advertises + * `capabilities.multipleChats.fork`, and `kind: "sideChat"` when the + * selected agent advertises `capabilities.multipleChats.sideChat`. Both + * source forms carry a stable top-level `turnId`. Forks target completed + * turns. Side chats also carry a stable `turnId`, which the host resolves + * against the source chat's current active turn or retained history. If it + * resolves to the active turn, the host snapshots the currently available + * partial response when accepting `createChat`. */ source?: ChatSource; /** * Initial working-directory subset for this chat. Every entry MUST be * present in the owning session's `workingDirectories`; the server MUST * reject any entry that is not. When absent, the chat inherits the full - * session set. Forked chats (those whose `source` uses the flat `chat` + - * `turnId` shape) inherit the source chat's `workingDirectories`; this field - * is ignored for forks. + * session set. Forked chats (those whose `source.kind` is `"fork"`) inherit + * the source chat's `workingDirectories`; this field is ignored for forks. * * A client MUST NOT supply this field unless the agent advertises * {@link AgentCapabilities.multipleWorkingDirectories}. @@ -117,8 +115,7 @@ export interface CreateChatParams extends BaseParams { * reject creation that omits it, or fall back to the first of the chat's * directories. Fixed at creation and reported (read-only) on * {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose - * `source` uses the flat `chat` + `turnId` shape inherits the source chat's - * primary). + * `source.kind` is `"fork"` inherits the source chat's primary). */ primaryWorkingDirectory?: URI; } diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index 91c427d8a..cc6ac64cf 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -185,10 +185,10 @@ export const enum ChatOriginKind { * How a chat came into existence. Clients MAY use it to render * contextual UI (parent indicators, fork markers, "spawned by tool" badges). * - * Fork origins preserve the existing flat `chat` + `turnId` wire shape. Side - * chat origins likewise carry a stable `turnId` identity alongside - * `kind: "sideChat"` instead of snapshotting whether that turn was active or - * historical at creation time. Consumers resolve the identifier against the + * Fork and side-chat origins both carry a stable top-level `turnId` alongside + * their discriminated `kind` value instead of snapshotting whether that turn + * was active or historical at creation time. Consumers resolve the identifier + * against the * source chat's current `activeTurn` or retained `turns` as needed. * * When a host accepts side-chat creation from the source chat's current active diff --git a/types/channels-root/state.ts b/types/channels-root/state.ts index a7de4c0fa..5b05e45c8 100644 --- a/types/channels-root/state.ts +++ b/types/channels-root/state.ts @@ -132,8 +132,8 @@ export interface AgentCapabilities { export interface MultipleChatsCapability { /** * The agent can fork a chat from a specific turn. When absent or `false`, - * clients MUST NOT pass a fork-shaped {@link ChatSource} (`chat` + `turnId`) - * to `createChat`. + * clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to + * `createChat`. * Forking always implies multi-chat support. */ fork?: boolean; diff --git a/types/test-cases/round-trips/036-chat-source-fork-flat.json b/types/test-cases/round-trips/036-chat-source-fork.json similarity index 51% rename from types/test-cases/round-trips/036-chat-source-fork-flat.json rename to types/test-cases/round-trips/036-chat-source-fork.json index 3ecc4382b..d7dade5a9 100644 --- a/types/test-cases/round-trips/036-chat-source-fork-flat.json +++ b/types/test-cases/round-trips/036-chat-source-fork.json @@ -1,14 +1,16 @@ { - "name": "chat-source-fork-flat", + "name": "chat-source-fork", "group": "A", - "description": "ChatSource preserves the 0.7.x flat fork wire format with a top-level turnId.", + "description": "ChatSource requires an explicit fork discriminator alongside its source chat and stable completed-turn turnId.", "type": "ChatSource", "input": { + "kind": "fork", "chat": "ahp-chat:/main", "turnId": "turn-12" }, "acceptableOutputs": [ { + "kind": "fork", "chat": "ahp-chat:/main", "turnId": "turn-12" } diff --git a/types/test-cases/round-trips/037-chat-origin-fork-flat.json b/types/test-cases/round-trips/037-chat-origin-fork.json similarity index 84% rename from types/test-cases/round-trips/037-chat-origin-fork-flat.json rename to types/test-cases/round-trips/037-chat-origin-fork.json index e516dcbf1..30dd95f5b 100644 --- a/types/test-cases/round-trips/037-chat-origin-fork-flat.json +++ b/types/test-cases/round-trips/037-chat-origin-fork.json @@ -1,7 +1,7 @@ { - "name": "chat-origin-fork-flat", + "name": "chat-origin-fork", "group": "A", - "description": "A session/chatAdded action preserves flat fork provenance in ChatOrigin state.", + "description": "A session/chatAdded action preserves discriminated fork provenance in ChatOrigin state.", "type": "StateAction", "input": { "type": "session/chatAdded", From ccec6483dfcb0fbf1bd9381beebf19c21215f311 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 23 Jul 2026 14:19:23 -0700 Subject: [PATCH 8/9] chat: enforce chat source discriminators Force generated ChatSource branches to serialize their exact discriminants in Go, Kotlin, and Swift, and cover the public APIs with exact-kind tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahptypes/chat_source_test.go | 55 +++++++ clients/go/ahptypes/commands.generated.go | 56 +++++++ .../generated/Commands.generated.kt | 149 +++++++++++++----- .../DiscriminatedUnionTest.kt | 23 ++- .../Generated/Commands.generated.swift | 139 ++++++++++------ .../ChatSourceTests.swift | 20 +++ scripts/generate-go.ts | 40 +++++ scripts/generate-kotlin.ts | 71 ++++++++- scripts/generate-swift.ts | 69 +++++++- 9 files changed, 530 insertions(+), 92 deletions(-) diff --git a/clients/go/ahptypes/chat_source_test.go b/clients/go/ahptypes/chat_source_test.go index 3184b4e10..c3e212a19 100644 --- a/clients/go/ahptypes/chat_source_test.go +++ b/clients/go/ahptypes/chat_source_test.go @@ -53,3 +53,58 @@ func TestChatSourceRejectsMissingOrUnknownKind(t *testing.T) { }) } } + +func TestChatSourceSerializationForcesExactKinds(t *testing.T) { + t.Run("fork branch and union ignore contradictory kind", func(t *testing.T) { + branch := ForkChatSource{ + Kind: ChatSourceKindSideChat, + Chat: "ahp-chat:/main", + TurnId: "turn-12", + } + + branchRaw, err := json.Marshal(branch) + if err != nil { + t.Fatalf("marshal fork branch: %v", err) + } + unionRaw, err := json.Marshal(ChatSource{Value: &branch}) + if err != nil { + t.Fatalf("marshal fork union: %v", err) + } + + for _, raw := range [][]byte{branchRaw, unionRaw} { + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("decode serialized fork payload: %v", err) + } + if got := decoded["kind"]; got != "fork" { + t.Fatalf("expected fork kind, got %#v in %s", got, raw) + } + } + }) + + t.Run("sideChat branch and union ignore zero kind", func(t *testing.T) { + branch := SideChatSource{ + Chat: "ahp-chat:/main", + TurnId: "turn-active", + } + + branchRaw, err := json.Marshal(branch) + if err != nil { + t.Fatalf("marshal sideChat branch: %v", err) + } + unionRaw, err := json.Marshal(ChatSource{Value: &branch}) + if err != nil { + t.Fatalf("marshal sideChat union: %v", err) + } + + for _, raw := range [][]byte{branchRaw, unionRaw} { + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("decode serialized sideChat payload: %v", err) + } + if got := decoded["kind"]; got != "sideChat" { + t.Fatalf("expected sideChat kind, got %#v in %s", got, raw) + } + } + }) +} diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index b3a9fb515..7a24a35a8 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -1119,6 +1119,62 @@ type ChangesetOperationFollowUp struct { External *bool `json:"external,omitempty"` } +func (v *ForkChatSource) UnmarshalJSON(data []byte) error { + disc, ok, err := readDiscriminator(data, "kind") + if err != nil { + return err + } + if !ok { + return missingDiscriminatorError("ForkChatSource", "kind") + } + if disc != "fork" { + return unknownDiscriminatorError("ForkChatSource", "kind", disc) + } + type wire ForkChatSource + var raw wire + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *v = ForkChatSource(raw) + v.Kind = ChatSourceKindFork + return nil +} + +func (v ForkChatSource) MarshalJSON() ([]byte, error) { + type wire ForkChatSource + raw := wire(v) + raw.Kind = ChatSourceKindFork + return json.Marshal(raw) +} + +func (v *SideChatSource) UnmarshalJSON(data []byte) error { + disc, ok, err := readDiscriminator(data, "kind") + if err != nil { + return err + } + if !ok { + return missingDiscriminatorError("SideChatSource", "kind") + } + if disc != "sideChat" { + return unknownDiscriminatorError("SideChatSource", "kind", disc) + } + type wire SideChatSource + var raw wire + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *v = SideChatSource(raw) + v.Kind = ChatSourceKindSideChat + return nil +} + +func (v SideChatSource) MarshalJSON() ([]byte, error) { + type wire SideChatSource + raw := wire(v) + raw.Kind = ChatSourceKindSideChat + return json.Marshal(raw) +} + // ─── ChatSource Union ───────────────────────────────────────────────── // ChatSource identifies how a new chat uses a source chat. 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 d9aa507ce..f7f8d29ff 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 @@ -120,6 +120,114 @@ enum class ResourceWriteMode { // ─── Command Types ────────────────────────────────────────────────────────── +/** + * Copies source history through a completed turn into the new chat. + */ +@Serializable(with = ForkChatSourceSerializer::class) +data class ForkChatSource( + /** + * URI of the existing source chat. + */ + val chat: URI, + /** + * Completed turn identifier in the source chat. + * + * Content through this turn is copied into the new chat's visible `turns`. + */ + val turnId: String, +) { + val kind: ChatSourceKind + get() = ChatSourceKind.FORK +} + +@Serializable +private data class ForkChatSourceWire( + val kind: ChatSourceKind, + val chat: URI, + val turnId: String, +) + +internal object ForkChatSourceSerializer : KSerializer { + override val descriptor: SerialDescriptor = ForkChatSourceWire.serializer().descriptor + + override fun deserialize(decoder: Decoder): ForkChatSource { + val input = decoder as? JsonDecoder + ?: error("ForkChatSource can only be deserialized from JSON") + val wire = input.json.decodeFromJsonElement(ForkChatSourceWire.serializer(), input.decodeJsonElement()) + if (wire.kind != ChatSourceKind.FORK) { + error("Expected ForkChatSource kind fork") + } + return ForkChatSource(chat = wire.chat, turnId = wire.turnId) + } + + override fun serialize(encoder: Encoder, value: ForkChatSource) { + val output = encoder as? JsonEncoder + ?: error("ForkChatSource can only be serialized to JSON") + val element = output.json.encodeToJsonElement( + ForkChatSourceWire.serializer(), + ForkChatSourceWire(kind = ChatSourceKind.FORK, chat = value.chat, turnId = value.turnId), + ) + output.encodeJsonElement(element) + } +} + +/** + * Supplies source context to a new side chat without copying it into the side + * chat's visible history. + */ +@Serializable(with = SideChatSourceSerializer::class) +data class SideChatSource( + /** + * URI of the existing source chat. + */ + val chat: URI, + /** + * Stable source-turn identifier in the source chat. + * + * Hosts resolve this id against the source chat's current `activeTurn` or its + * retained `turns` when accepting `createChat`. If it names the current + * active turn, the host snapshots the source chat's retained history plus + * that turn's current user message and any partial assistant response already + * available. Once that turn later becomes historical, it is still referenced + * by this same identifier. + */ + val turnId: String, +) { + val kind: ChatSourceKind + get() = ChatSourceKind.SIDE_CHAT +} + +@Serializable +private data class SideChatSourceWire( + val kind: ChatSourceKind, + val chat: URI, + val turnId: String, +) + +internal object SideChatSourceSerializer : KSerializer { + override val descriptor: SerialDescriptor = SideChatSourceWire.serializer().descriptor + + override fun deserialize(decoder: Decoder): SideChatSource { + val input = decoder as? JsonDecoder + ?: error("SideChatSource can only be deserialized from JSON") + val wire = input.json.decodeFromJsonElement(SideChatSourceWire.serializer(), input.decodeJsonElement()) + if (wire.kind != ChatSourceKind.SIDE_CHAT) { + error("Expected SideChatSource kind sideChat") + } + return SideChatSource(chat = wire.chat, turnId = wire.turnId) + } + + override fun serialize(encoder: Encoder, value: SideChatSource) { + val output = encoder as? JsonEncoder + ?: error("SideChatSource can only be serialized to JSON") + val element = output.json.encodeToJsonElement( + SideChatSourceWire.serializer(), + SideChatSourceWire(kind = ChatSourceKind.SIDE_CHAT, chat = value.chat, turnId = value.turnId), + ) + output.encodeJsonElement(element) + } +} + @Serializable data class InitializeParams( /** @@ -466,47 +574,6 @@ data class DisposeSessionParams( val channel: String ) -@Serializable -data class ForkChatSource( - /** - * Discriminant - */ - val kind: ChatSourceKind, - /** - * URI of the existing source chat. - */ - val chat: String, - /** - * Completed turn identifier in the source chat. - * - * Content through this turn is copied into the new chat's visible `turns`. - */ - val turnId: String -) - -@Serializable -data class SideChatSource( - /** - * Discriminant - */ - val kind: ChatSourceKind, - /** - * URI of the existing source chat. - */ - val chat: String, - /** - * Stable source-turn identifier in the source chat. - * - * Hosts resolve this id against the source chat's current `activeTurn` or its - * retained `turns` when accepting `createChat`. If it names the current - * active turn, the host snapshots the source chat's retained history plus - * that turn's current user message and any partial assistant response already - * available. Once that turn later becomes historical, it is still referenced - * by this same identifier. - */ - val turnId: String -) - @Serializable data class CreateChatParams( /** diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt index aaaafaf97..b389de8b6 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt @@ -140,16 +140,35 @@ class DiscriminatedUnionTest { val reEncodedFork = json.encodeToString( ChatSource.serializer(), - ChatSourceFork(ForkChatSource(kind = ChatSourceKind.FORK, chat = "ahp-chat:/main", turnId = "turn-12")), + ChatSourceFork(ForkChatSource(chat = "ahp-chat:/main", turnId = "turn-12")), ) val reEncodedSideChat = json.encodeToString( ChatSource.serializer(), - ChatSourceSideChat(SideChatSource(kind = ChatSourceKind.SIDE_CHAT, chat = "ahp-chat:/main", turnId = "turn-active")), + ChatSourceSideChat(SideChatSource(chat = "ahp-chat:/main", turnId = "turn-active")), ) assertEquals(JsonPrimitive("fork"), json.parseToJsonElement(reEncodedFork).jsonObject["kind"]) assertEquals(JsonPrimitive("sideChat"), json.parseToJsonElement(reEncodedSideChat).jsonObject["kind"]) } + @Test + fun `ChatSource branches serialize exact kinds`() { + val fork = ForkChatSource(chat = "ahp-chat:/main", turnId = "turn-12") + val sideChat = SideChatSource(chat = "ahp-chat:/main", turnId = "turn-active") + + assertEquals(ChatSourceKind.FORK, fork.kind) + assertEquals(ChatSourceKind.SIDE_CHAT, sideChat.kind) + + val encodedForkBranch = json.encodeToString(ForkChatSource.serializer(), fork) + val encodedSideChatBranch = json.encodeToString(SideChatSource.serializer(), sideChat) + val encodedForkUnion = json.encodeToString(ChatSource.serializer(), ChatSourceFork(fork)) + val encodedSideChatUnion = json.encodeToString(ChatSource.serializer(), ChatSourceSideChat(sideChat)) + + assertEquals(JsonPrimitive("fork"), json.parseToJsonElement(encodedForkBranch).jsonObject["kind"]) + assertEquals(JsonPrimitive("sideChat"), json.parseToJsonElement(encodedSideChatBranch).jsonObject["kind"]) + assertEquals(JsonPrimitive("fork"), json.parseToJsonElement(encodedForkUnion).jsonObject["kind"]) + assertEquals(JsonPrimitive("sideChat"), json.parseToJsonElement(encodedSideChatUnion).jsonObject["kind"]) + } + @Test fun `ChatSource rejects missing or unknown kind`() { val missingKind = """{"chat":"ahp-chat:/main","turnId":"turn-12"}""" diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index 5e71dab8e..a3e00f6cf 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -66,6 +66,98 @@ public enum ResourceWriteMode: String, Codable, Sendable { // MARK: - Command Types +/// Copies source history through a completed turn into the new chat. +public struct ForkChatSource: Codable, Sendable { + /// Discriminant + public var kind: ChatSourceKind { .fork } + /// URI of the existing source chat. + public var chat: URI + /// Completed turn identifier in the source chat. + /// + /// Content through this turn is copied into the new chat's visible `turns`. + public var turnId: String + + private enum CodingKeys: String, CodingKey { + case kind + case chat + case turnId + } + + public init( + chat: URI, + turnId: String + ) { + self.chat = chat + self.turnId = turnId + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(ChatSourceKind.self, forKey: .kind) + guard kind == .fork else { + throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Expected ForkChatSource kind fork") + } + self.chat = try container.decode(URI.self, forKey: .chat) + self.turnId = try container.decode(String.self, forKey: .turnId) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(ChatSourceKind.fork, forKey: .kind) + try container.encode(chat, forKey: .chat) + try container.encode(turnId, forKey: .turnId) + } +} + +/// Supplies source context to a new side chat without copying it into the side +/// chat's visible history. +public struct SideChatSource: Codable, Sendable { + /// Discriminant + public var kind: ChatSourceKind { .sideChat } + /// URI of the existing source chat. + public var chat: URI + /// Stable source-turn identifier in the source chat. + /// + /// Hosts resolve this id against the source chat's current `activeTurn` or its + /// retained `turns` when accepting `createChat`. If it names the current + /// active turn, the host snapshots the source chat's retained history plus + /// that turn's current user message and any partial assistant response already + /// available. Once that turn later becomes historical, it is still referenced + /// by this same identifier. + public var turnId: String + + private enum CodingKeys: String, CodingKey { + case kind + case chat + case turnId + } + + public init( + chat: URI, + turnId: String + ) { + self.chat = chat + self.turnId = turnId + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(ChatSourceKind.self, forKey: .kind) + guard kind == .sideChat else { + throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Expected SideChatSource kind sideChat") + } + self.chat = try container.decode(URI.self, forKey: .chat) + self.turnId = try container.decode(String.self, forKey: .turnId) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(ChatSourceKind.sideChat, forKey: .kind) + try container.encode(chat, forKey: .chat) + try container.encode(turnId, forKey: .turnId) + } +} + public struct InitializeParams: Codable, Sendable { /// Channel URI this command targets. public var channel: String @@ -454,53 +546,6 @@ public struct DisposeSessionParams: Codable, Sendable { } } -public struct ForkChatSource: Codable, Sendable { - /// Discriminant - public var kind: ChatSourceKind - /// URI of the existing source chat. - public var chat: String - /// Completed turn identifier in the source chat. - /// - /// Content through this turn is copied into the new chat's visible `turns`. - public var turnId: String - - public init( - kind: ChatSourceKind, - chat: String, - turnId: String - ) { - self.kind = kind - self.chat = chat - self.turnId = turnId - } -} - -public struct SideChatSource: Codable, Sendable { - /// Discriminant - public var kind: ChatSourceKind - /// URI of the existing source chat. - public var chat: String - /// Stable source-turn identifier in the source chat. - /// - /// Hosts resolve this id against the source chat's current `activeTurn` or its - /// retained `turns` when accepting `createChat`. If it names the current - /// active turn, the host snapshots the source chat's retained history plus - /// that turn's current user message and any partial assistant response already - /// available. Once that turn later becomes historical, it is still referenced - /// by this same identifier. - public var turnId: String - - public init( - kind: ChatSourceKind, - chat: String, - turnId: String - ) { - self.kind = kind - self.chat = chat - self.turnId = turnId - } -} - public struct CreateChatParams: Codable, Sendable { /// Channel URI this command targets. public var channel: String diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ChatSourceTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ChatSourceTests.swift index 330de5441..16b12f97a 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ChatSourceTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ChatSourceTests.swift @@ -48,4 +48,24 @@ final class ChatSourceTests: XCTestCase { ) ) } + + func testChatSourceBranchesEncodeFixedKinds() throws { + let encoder = JSONEncoder() + + let fork = ForkChatSource(chat: "ahp-chat:/main", turnId: "turn-12") + let sideChat = SideChatSource(chat: "ahp-chat:/main", turnId: "turn-active") + + XCTAssertEqual(fork.kind, .fork) + XCTAssertEqual(sideChat.kind, .sideChat) + + let forkBranch = try JSONSerialization.jsonObject(with: encoder.encode(fork)) as? [String: Any] + let sideChatBranch = try JSONSerialization.jsonObject(with: encoder.encode(sideChat)) as? [String: Any] + let forkUnion = try JSONSerialization.jsonObject(with: encoder.encode(ChatSource.fork(fork))) as? [String: Any] + let sideChatUnion = try JSONSerialization.jsonObject(with: encoder.encode(ChatSource.sideChat(sideChat))) as? [String: Any] + + XCTAssertEqual(forkBranch?["kind"] as? String, "fork") + XCTAssertEqual(sideChatBranch?["kind"] as? String, "sideChat") + XCTAssertEqual(forkUnion?["kind"] as? String, "fork") + XCTAssertEqual(sideChatUnion?["kind"] as? String, "sideChat") + } } diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 6b83657d5..8241234f7 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -512,6 +512,41 @@ function generateStructFromInterface( return generateGoStruct(name, props, { doc: ifaceDoc, ...opts }); } +function generateFixedDiscriminantMethods( + goName: string, + discriminantField: string, + discriminantValue: string, + discriminantType: string, +): string { + return `func (v *${goName}) UnmarshalJSON(data []byte) error { +\tdisc, ok, err := readDiscriminator(data, ${JSON.stringify(discriminantField)}) +\tif err != nil { +\t\treturn err +\t} +\tif !ok { +\t\treturn missingDiscriminatorError(${JSON.stringify(goName)}, ${JSON.stringify(discriminantField)}) +\t} +\tif disc != ${JSON.stringify(discriminantValue)} { +\t\treturn unknownDiscriminatorError(${JSON.stringify(goName)}, ${JSON.stringify(discriminantField)}, disc) +\t} +\ttype wire ${goName} +\tvar raw wire +\tif err := json.Unmarshal(data, &raw); err != nil { +\t\treturn err +\t} +\t*v = ${goName}(raw) +\tv.${toPascalCase(discriminantField)} = ${discriminantType}${toPascalCase(discriminantValue)} +\treturn nil +} + +func (v ${goName}) MarshalJSON() ([]byte, error) { +\ttype wire ${goName} +\traw := wire(v) +\traw.${toPascalCase(discriminantField)} = ${discriminantType}${toPascalCase(discriminantValue)} +\treturn json.Marshal(raw) +}`; +} + // ─── Partial Struct Generation ─────────────────────────────────────────────── function generatePartialStruct(project: Project, tsInterfaceName: string): string { @@ -1630,6 +1665,11 @@ function generateCommandsFile(project: Project): string { } } + lines.push(generateFixedDiscriminantMethods('ForkChatSource', 'kind', 'fork', 'ChatSourceKind')); + lines.push(''); + lines.push(generateFixedDiscriminantMethods('SideChatSource', 'kind', 'sideChat', 'ChatSourceKind')); + lines.push(''); + lines.push('// ─── ChatSource Union ─────────────────────────────────────────────────\n'); lines.push(generateDiscriminatedUnion(CHAT_SOURCE_UNION)); lines.push(''); diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 9b887f982..964557418 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -590,6 +590,57 @@ function generateDataClassFromInterface( return generateKotlinDataClass(name, props); } +function generateFixedChatSourceBranchKotlin( + name: 'ForkChatSource' | 'SideChatSource', + kindMember: 'FORK' | 'SIDE_CHAT', + kindWire: 'fork' | 'sideChat', + doc: string, + turnIdDoc: string, +): string { + return `${emitKDoc(doc).join('\n')} +@Serializable(with = ${name}Serializer::class) +data class ${name}( +${emitKDoc('URI of the existing source chat.', ' ').join('\n')} + val chat: URI, +${emitKDoc(turnIdDoc, ' ').join('\n')} + val turnId: String, +) { + val kind: ChatSourceKind + get() = ChatSourceKind.${kindMember} +} + +@Serializable +private data class ${name}Wire( + val kind: ChatSourceKind, + val chat: URI, + val turnId: String, +) + +internal object ${name}Serializer : KSerializer<${name}> { + override val descriptor: SerialDescriptor = ${name}Wire.serializer().descriptor + + override fun deserialize(decoder: Decoder): ${name} { + val input = decoder as? JsonDecoder + ?: error("${name} can only be deserialized from JSON") + val wire = input.json.decodeFromJsonElement(${name}Wire.serializer(), input.decodeJsonElement()) + if (wire.kind != ChatSourceKind.${kindMember}) { + error("Expected ${name} kind ${kindWire}") + } + return ${name}(chat = wire.chat, turnId = wire.turnId) + } + + override fun serialize(encoder: Encoder, value: ${name}) { + val output = encoder as? JsonEncoder + ?: error("${name} can only be serialized to JSON") + val element = output.json.encodeToJsonElement( + ${name}Wire.serializer(), + ${name}Wire(kind = ChatSourceKind.${kindMember}, chat = value.chat, turnId = value.turnId), + ) + output.encodeJsonElement(element) + } +}`; +} + /** * Emit a Kotlin counterpart for `Partial`: same properties as `T` but with * every field forced nullable. The synthetic data class is referenced by @@ -1446,7 +1497,7 @@ const COMMAND_STRUCTS = [ 'ReconnectParams', 'ReconnectReplayResult', 'ReconnectSnapshotResult', 'SubscribeParams', 'SubscribeView', 'SubscriptionDeliveryOptions', 'SubscribeResult', 'SessionForkSource', 'CreateSessionParams', 'DisposeSessionParams', - 'ForkChatSource', 'SideChatSource', 'CreateChatParams', 'DisposeChatParams', + 'CreateChatParams', 'DisposeChatParams', 'ListSessionsParams', 'ListSessionsResult', 'ResourceReadParams', 'ResourceReadResult', 'ResourceWriteParams', 'ResourceWriteResult', @@ -1576,6 +1627,22 @@ function generateCommandsFile(project: Project): string { lines.push('// ─── Command Types ──────────────────────────────────────────────────────────'); lines.push(''); + lines.push(generateFixedChatSourceBranchKotlin( + 'ForkChatSource', + 'FORK', + 'fork', + 'Copies source history through a completed turn into the new chat.', + 'Completed turn identifier in the source chat.\n\nContent through this turn is copied into the new chat\'s visible `turns`.', + )); + lines.push(''); + lines.push(generateFixedChatSourceBranchKotlin( + 'SideChatSource', + 'SIDE_CHAT', + 'sideChat', + 'Supplies source context to a new side chat without copying it into the side\nchat\'s visible history.', + 'Stable source-turn identifier in the source chat.\n\nHosts resolve this id against the source chat\'s current `activeTurn` or its\nretained `turns` when accepting `createChat`. If it names the current\nactive turn, the host snapshots the source chat\'s retained history plus\nthat turn\'s current user message and any partial assistant response already\navailable. Once that turn later becomes historical, it is still referenced\nby this same identifier.', + )); + lines.push(''); const generated = new Set(); for (const ifaceName of COMMAND_STRUCTS) { if (generated.has(ifaceName)) continue; @@ -1989,6 +2056,8 @@ function checkExhaustiveness(project: Project): void { 'AhpErrorCodeWithData', // type-level alias; not a Kotlin type 'JsonRpcErrorCode', // type-level alias over JsonRpcErrorCodes const enum 'ReconnectResult', // RECONNECT_RESULT_UNION discriminated union + 'ForkChatSource', // generateFixedChatSourceBranchKotlin() + 'SideChatSource', // generateFixedChatSourceBranchKotlin() 'ChangesetOperationTarget', // generateChangesetOperationTargetKotlin() ]); diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 98514674b..99e61ed9f 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -515,6 +515,57 @@ function generateStructFromInterface( return generateSwiftStruct(name, props); } +function generateFixedChatSourceBranchSwift( + name: 'ForkChatSource' | 'SideChatSource', + kindCase: 'fork' | 'sideChat', + doc: string, + turnIdDoc: string, +): string { + const lines = [ + ...doc.split('\n').map(line => emitSwiftDocLine(line)), + `public struct ${name}: Codable, Sendable {`, + ' /// Discriminant', + ` public var kind: ChatSourceKind { .${kindCase} }`, + ' /// URI of the existing source chat.', + ' public var chat: URI', + ...turnIdDoc.split('\n').map(line => emitSwiftDocLine(line, ' ')), + ' public var turnId: String', + '', + ' private enum CodingKeys: String, CodingKey {', + ' case kind', + ' case chat', + ' case turnId', + ' }', + '', + ' public init(', + ' chat: URI,', + ' turnId: String', + ' ) {', + ' self.chat = chat', + ' self.turnId = turnId', + ' }', + '', + ' public init(from decoder: Decoder) throws {', + ' let container = try decoder.container(keyedBy: CodingKeys.self)', + ' let kind = try container.decode(ChatSourceKind.self, forKey: .kind)', + ` guard kind == .${kindCase} else {`, + ` throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Expected ${name} kind ${kindCase}")`, + ' }', + ' self.chat = try container.decode(URI.self, forKey: .chat)', + ' self.turnId = try container.decode(String.self, forKey: .turnId)', + ' }', + '', + ' public func encode(to encoder: Encoder) throws {', + ' var container = encoder.container(keyedBy: CodingKeys.self)', + ` try container.encode(ChatSourceKind.${kindCase}, forKey: .kind)`, + ' try container.encode(chat, forKey: .chat)', + ' try container.encode(turnId, forKey: .turnId)', + ' }', + '}', + ]; + return lines.join('\n'); +} + /** * Emit a Swift counterpart for `Partial`: same properties as `T` but with * every field forced optional. The synthetic struct is referenced by @@ -1369,7 +1420,7 @@ const COMMAND_STRUCTS = [ 'ReconnectParams', 'ReconnectReplayResult', 'ReconnectSnapshotResult', 'SubscribeParams', 'SubscribeView', 'SubscriptionDeliveryOptions', 'SubscribeResult', 'SessionForkSource', 'CreateSessionParams', 'DisposeSessionParams', - 'ForkChatSource', 'SideChatSource', 'CreateChatParams', 'DisposeChatParams', + 'CreateChatParams', 'DisposeChatParams', 'ListSessionsParams', 'ListSessionsResult', 'ResourceReadParams', 'ResourceReadResult', 'ResourceWriteParams', 'ResourceWriteResult', @@ -1425,6 +1476,20 @@ function generateCommandsFile(project: Project): string { } lines.push('// MARK: - Command Types\n'); + lines.push(generateFixedChatSourceBranchSwift( + 'ForkChatSource', + 'fork', + 'Copies source history through a completed turn into the new chat.', + 'Completed turn identifier in the source chat.\n\nContent through this turn is copied into the new chat\'s visible `turns`.', + )); + lines.push(''); + lines.push(generateFixedChatSourceBranchSwift( + 'SideChatSource', + 'sideChat', + 'Supplies source context to a new side chat without copying it into the side\nchat\'s visible history.', + 'Stable source-turn identifier in the source chat.\n\nHosts resolve this id against the source chat\'s current `activeTurn` or its\nretained `turns` when accepting `createChat`. If it names the current\nactive turn, the host snapshots the source chat\'s retained history plus\nthat turn\'s current user message and any partial assistant response already\navailable. Once that turn later becomes historical, it is still referenced\nby this same identifier.', + )); + lines.push(''); // Track which interfaces we've already generated to handle duplicates const generated = new Set(); for (const ifaceName of COMMAND_STRUCTS) { @@ -2030,6 +2095,8 @@ function checkExhaustiveness(project: Project): void { 'AhpErrorCodeWithData', // type-level alias; not a Swift type 'JsonRpcErrorCode', // type-level alias over JsonRpcErrorCodes const enum 'ReconnectResult', // RECONNECT_RESULT_UNION discriminated union + 'ForkChatSource', // generateFixedChatSourceBranchSwift() + 'SideChatSource', // generateFixedChatSourceBranchSwift() 'ChangesetOperationTarget', // TS discriminated union; consumers should add a Swift case-iterable enum ]); From 3cb2cee484681e7de6d7748d900653b728f2d072 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 23 Jul 2026 16:03:55 -0700 Subject: [PATCH 9/9] chat: add side chat text selection Persist an immutable selected-text snapshot, with optional response-part provenance, on side-chat sources and origins. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahptypes/chat_source_test.go | 17 +++++- clients/go/ahptypes/commands.generated.go | 11 +++- clients/go/ahptypes/state.generated.go | 24 +++++++- .../generated/Commands.generated.kt | 17 +++++- .../generated/State.generated.kt | 19 +++++++ .../DiscriminatedUnionTest.kt | 27 ++++++++- clients/rust/crates/ahp-types/src/actions.rs | 6 +- clients/rust/crates/ahp-types/src/commands.rs | 16 +++++- clients/rust/crates/ahp-types/src/state.rs | 23 ++++++++ .../crates/ahp-types/tests/chat_source.rs | 5 +- .../Generated/Commands.generated.swift | 15 ++++- .../Generated/State.generated.swift | 25 ++++++++- .../ChatSourceTests.swift | 12 +++- .../20260723-side-chat-selection.json | 4 ++ docs/guide/state-model.md | 17 ++++-- docs/proposals/multi-chat.md | 13 +++-- docs/specification/chat-channel.md | 18 ++++-- schema/actions.schema.json | 22 +++++++- schema/commands.schema.json | 28 +++++++++- schema/errors.schema.json | 28 +++++++++- schema/notifications.schema.json | 22 +++++++- schema/state.schema.json | 22 +++++++- scripts/generate-go.ts | 2 + scripts/generate-json-schema.test.ts | 19 +++++++ scripts/generate-kotlin.ts | 24 ++++++-- scripts/generate-rust.ts | 8 ++- scripts/generate-swift.ts | 20 ++++++- types/channels-chat/commands.ts | 15 ++++- types/channels-chat/state.ts | 31 +++++++++- types/index.ts | 1 + ...hatadded-preserves-sidechat-selection.json | 56 +++++++++++++++++++ .../038-chat-source-side-chat-selection.json | 26 +++++++++ .../039-side-chat-origin-selection.json | 44 +++++++++++++++ 33 files changed, 579 insertions(+), 58 deletions(-) create mode 100644 docs/.changes/20260723-side-chat-selection.json create mode 100644 types/test-cases/reducers/256-session-chatadded-preserves-sidechat-selection.json create mode 100644 types/test-cases/round-trips/038-chat-source-side-chat-selection.json create mode 100644 types/test-cases/round-trips/039-side-chat-origin-selection.json diff --git a/clients/go/ahptypes/chat_source_test.go b/clients/go/ahptypes/chat_source_test.go index c3e212a19..353841280 100644 --- a/clients/go/ahptypes/chat_source_test.go +++ b/clients/go/ahptypes/chat_source_test.go @@ -23,7 +23,7 @@ func TestChatSourceRoutesByKind(t *testing.T) { t.Run("sideChat", func(t *testing.T) { var value ChatSource - if err := json.Unmarshal([]byte(`{"kind":"sideChat","chat":"ahp-chat:/main","turnId":"turn-active"}`), &value); err != nil { + if err := json.Unmarshal([]byte(`{"kind":"sideChat","chat":"ahp-chat:/main","turnId":"turn-active","selection":{"text":"const value = compute()","responsePartId":"part-7"}}`), &value); err != nil { t.Fatalf("decode sideChat: %v", err) } sideChat, ok := value.Value.(*SideChatSource) @@ -33,6 +33,9 @@ func TestChatSourceRoutesByKind(t *testing.T) { if sideChat.Kind != ChatSourceKindSideChat || sideChat.TurnId != "turn-active" { t.Fatalf("unexpected sideChat payload: %#v", sideChat) } + if sideChat.Selection == nil || sideChat.Selection.Text != "const value = compute()" || sideChat.Selection.ResponsePartId == nil || *sideChat.Selection.ResponsePartId != "part-7" { + t.Fatalf("unexpected sideChat selection: %#v", sideChat.Selection) + } }) } @@ -86,6 +89,10 @@ func TestChatSourceSerializationForcesExactKinds(t *testing.T) { branch := SideChatSource{ Chat: "ahp-chat:/main", TurnId: "turn-active", + Selection: &SideChatSelection{ + Text: "const value = compute()", + ResponsePartId: stringPtr("part-7"), + }, } branchRaw, err := json.Marshal(branch) @@ -105,6 +112,14 @@ func TestChatSourceSerializationForcesExactKinds(t *testing.T) { if got := decoded["kind"]; got != "sideChat" { t.Fatalf("expected sideChat kind, got %#v in %s", got, raw) } + selection, ok := decoded["selection"].(map[string]any) + if !ok || selection["text"] != "const value = compute()" || selection["responsePartId"] != "part-7" { + t.Fatalf("expected sideChat selection to round-trip, got %#v in %s", decoded["selection"], raw) + } } }) } + +func stringPtr(value string) *string { + return &value +} diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index 7a24a35a8..d00d06749 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -412,6 +412,12 @@ type SideChatSource struct { // available. Once that turn later becomes historical, it is still referenced // by this same identifier. TurnId string `json:"turnId"` + // Optional immutable selected-text snapshot to carry into the created side + // chat's origin. + // + // When present, the host MUST snapshot and preserve this exact selection when + // it accepts `createChat`; later source-turn deltas do not alter it. + Selection *SideChatSelection `json:"selection,omitempty"` } // Creates a new chat within a session. @@ -432,7 +438,10 @@ type CreateChatParams struct { // turns. Side chats also carry a stable `turnId`, which the host resolves // against the source chat's current active turn or retained history. If it // resolves to the active turn, the host snapshots the currently available - // partial response when accepting `createChat`. + // partial response when accepting `createChat`. When + // `source.kind === "sideChat"` and `source.selection` is present, the host + // also snapshots and preserves that exact selected text in the created chat's + // origin; any `responsePartId` there is provenance only, not a live range. Source *ChatSource `json:"source,omitempty"` // Initial working-directory subset for this chat. Every entry MUST be // present in the owning session's `workingDirectories`; the server MUST diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index eb9b230b1..d45e74afd 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -1183,6 +1183,23 @@ type ChatSummary struct { PrimaryWorkingDirectory *URI `json:"primaryWorkingDirectory,omitempty"` } +// Immutable selected-text snapshot captured when a side chat is created. +// +// The host records this exact text when it accepts `createChat`; later changes +// to the source chat do not alter it. +type SideChatSelection struct { + // Exact selected-text snapshot captured at `createChat` acceptance. + // + // MUST be non-empty. + Text string `json:"text"` + // Optional provenance for the response part that contained {@link text} when + // the host took the snapshot. + // + // Advisory only: this is not a live range or offset and MUST NOT be used to + // recompute `text`. + ResponsePartId *string `json:"responsePartId,omitempty"` +} + // A message queued for future delivery to the agent. // // Steering messages are injected into the current turn mid-flight. @@ -4811,9 +4828,10 @@ type ChatForkOrigin struct { func (*ChatForkOrigin) isChatOrigin() {} type ChatSideChatOrigin struct { - Kind ChatOriginKind `json:"kind"` - Chat URI `json:"chat"` - TurnId string `json:"turnId"` + Kind ChatOriginKind `json:"kind"` + Chat URI `json:"chat"` + TurnId string `json:"turnId"` + Selection *SideChatSelection `json:"selection,omitempty"` } func (*ChatSideChatOrigin) isChatOrigin() {} 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 f7f8d29ff..3d63475ec 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 @@ -135,6 +135,7 @@ data class ForkChatSource( * Content through this turn is copied into the new chat's visible `turns`. */ val turnId: String, + ) { val kind: ChatSourceKind get() = ChatSourceKind.FORK @@ -192,6 +193,12 @@ data class SideChatSource( * by this same identifier. */ val turnId: String, + /** + * Optional immutable selected-text snapshot to carry into the created side + * chat's origin. + */ + val selection: SideChatSelection? = null, + ) { val kind: ChatSourceKind get() = ChatSourceKind.SIDE_CHAT @@ -202,6 +209,7 @@ private data class SideChatSourceWire( val kind: ChatSourceKind, val chat: URI, val turnId: String, + val selection: SideChatSelection? = null, ) internal object SideChatSourceSerializer : KSerializer { @@ -214,7 +222,7 @@ internal object SideChatSourceSerializer : KSerializer { if (wire.kind != ChatSourceKind.SIDE_CHAT) { error("Expected SideChatSource kind sideChat") } - return SideChatSource(chat = wire.chat, turnId = wire.turnId) + return SideChatSource(chat = wire.chat, turnId = wire.turnId, selection = wire.selection) } override fun serialize(encoder: Encoder, value: SideChatSource) { @@ -222,7 +230,7 @@ internal object SideChatSourceSerializer : KSerializer { ?: error("SideChatSource can only be serialized to JSON") val element = output.json.encodeToJsonElement( SideChatSourceWire.serializer(), - SideChatSourceWire(kind = ChatSourceKind.SIDE_CHAT, chat = value.chat, turnId = value.turnId), + SideChatSourceWire(kind = ChatSourceKind.SIDE_CHAT, chat = value.chat, turnId = value.turnId, selection = value.selection), ) output.encodeJsonElement(element) } @@ -599,7 +607,10 @@ data class CreateChatParams( * turns. Side chats also carry a stable `turnId`, which the host resolves * against the source chat's current active turn or retained history. If it * resolves to the active turn, the host snapshots the currently available - * partial response when accepting `createChat`. + * partial response when accepting `createChat`. When + * `source.kind === "sideChat"` and `source.selection` is present, the host + * also snapshots and preserves that exact selected text in the created chat's + * origin; any `responsePartId` there is provenance only, not a live range. */ val source: ChatSource? = null, /** 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 8bb91b382..1cc71bd9f 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 @@ -1327,6 +1327,24 @@ data class ChatSummary( val primaryWorkingDirectory: String? = null ) +@Serializable +data class SideChatSelection( + /** + * Exact selected-text snapshot captured at `createChat` acceptance. + * + * MUST be non-empty. + */ + val text: String, + /** + * Optional provenance for the response part that contained {@link text} when + * the host took the snapshot. + * + * Advisory only: this is not a live range or offset and MUST NOT be used to + * recompute `text`. + */ + val responsePartId: String? = null +) + @Serializable data class SessionState( /** @@ -4668,6 +4686,7 @@ data class ChatOriginSideChat( val kind: ChatOriginKind = ChatOriginKind.SIDE_CHAT, val chat: String, val turnId: String, + val selection: SideChatSelection? = null, ) internal object ChatOriginSerializer : KSerializer { diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt index b389de8b6..fbe2e4c48 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt @@ -24,6 +24,7 @@ import com.microsoft.agenthostprotocol.generated.ChatSourceKind import com.microsoft.agenthostprotocol.generated.ChatSourceSideChat import com.microsoft.agenthostprotocol.generated.ForkChatSource import com.microsoft.agenthostprotocol.generated.SideChatSource +import com.microsoft.agenthostprotocol.generated.SideChatSelection import com.microsoft.agenthostprotocol.generated.StringOrMarkdown import com.microsoft.agenthostprotocol.generated.ToolResultContent import kotlinx.serialization.json.JsonObject @@ -126,7 +127,7 @@ class DiscriminatedUnionTest { @Test fun `ChatSource fork and sideChat route by kind`() { val forkWire = """{"kind":"fork","chat":"ahp-chat:/main","turnId":"turn-12"}""" - val sideChatWire = """{"kind":"sideChat","chat":"ahp-chat:/main","turnId":"turn-active"}""" + val sideChatWire = """{"kind":"sideChat","chat":"ahp-chat:/main","turnId":"turn-active","selection":{"text":"const value = compute()","responsePartId":"part-7"}}""" val fork = json.decodeFromString(ChatSource.serializer(), forkWire) val sideChat = json.decodeFromString(ChatSource.serializer(), sideChatWire) @@ -137,6 +138,8 @@ class DiscriminatedUnionTest { assertEquals("turn-12", asFork.value.turnId) assertEquals(ChatSourceKind.SIDE_CHAT, asSideChat.value.kind) assertEquals("turn-active", asSideChat.value.turnId) + assertEquals("const value = compute()", asSideChat.value.selection?.text) + assertEquals("part-7", asSideChat.value.selection?.responsePartId) val reEncodedFork = json.encodeToString( ChatSource.serializer(), @@ -144,16 +147,33 @@ class DiscriminatedUnionTest { ) val reEncodedSideChat = json.encodeToString( ChatSource.serializer(), - ChatSourceSideChat(SideChatSource(chat = "ahp-chat:/main", turnId = "turn-active")), + ChatSourceSideChat( + SideChatSource( + chat = "ahp-chat:/main", + turnId = "turn-active", + selection = SideChatSelection( + text = "const value = compute()", + responsePartId = "part-7", + ), + ), + ), ) assertEquals(JsonPrimitive("fork"), json.parseToJsonElement(reEncodedFork).jsonObject["kind"]) assertEquals(JsonPrimitive("sideChat"), json.parseToJsonElement(reEncodedSideChat).jsonObject["kind"]) + assertEquals(JsonPrimitive("const value = compute()"), json.parseToJsonElement(reEncodedSideChat).jsonObject["selection"]?.jsonObject?.get("text")) } @Test fun `ChatSource branches serialize exact kinds`() { val fork = ForkChatSource(chat = "ahp-chat:/main", turnId = "turn-12") - val sideChat = SideChatSource(chat = "ahp-chat:/main", turnId = "turn-active") + val sideChat = SideChatSource( + chat = "ahp-chat:/main", + turnId = "turn-active", + selection = SideChatSelection( + text = "const value = compute()", + responsePartId = "part-7", + ), + ) assertEquals(ChatSourceKind.FORK, fork.kind) assertEquals(ChatSourceKind.SIDE_CHAT, sideChat.kind) @@ -167,6 +187,7 @@ class DiscriminatedUnionTest { assertEquals(JsonPrimitive("sideChat"), json.parseToJsonElement(encodedSideChatBranch).jsonObject["kind"]) assertEquals(JsonPrimitive("fork"), json.parseToJsonElement(encodedForkUnion).jsonObject["kind"]) assertEquals(JsonPrimitive("sideChat"), json.parseToJsonElement(encodedSideChatUnion).jsonObject["kind"]) + assertEquals(JsonPrimitive("part-7"), json.parseToJsonElement(encodedSideChatUnion).jsonObject["selection"]?.jsonObject?.get("responsePartId")) } @Test diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index aa2ad448d..1ff1b20f4 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -18,9 +18,9 @@ use crate::state::{ ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ChatSummary, ConfirmationOption, Customization, ErrorInfo, McpAuthRequirement, McpServerState, Message, ModelSelection, PendingMessageKind, ResponsePart, SessionActiveClient, SessionInputRequest, - TerminalClaim, TerminalInfo, TextRange, ToolCallCancellationReason, ToolCallConfirmationReason, - ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolDefinition, ToolResultContent, - Turn, UsageInfo, + SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallCancellationReason, + ToolCallConfirmationReason, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, + ToolDefinition, ToolResultContent, Turn, UsageInfo, }; // ─── ActionType ────────────────────────────────────────────────────── diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index 1278cb28a..6f33ec465 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -16,8 +16,8 @@ use crate::actions::{ActionEnvelope, StateAction}; #[allow(unused_imports)] use crate::state::{ AgentSelection, ContentRef, Message, MessageAttachment, ModelSelection, SessionActiveClient, - SessionConfigSchema, SessionSummary, Snapshot, SnapshotState, TelemetryCapabilities, - TerminalClaim, TextRange, Turn, + SessionConfigSchema, SessionSummary, SideChatSelection, Snapshot, SnapshotState, + TelemetryCapabilities, TerminalClaim, TextRange, Turn, }; // ─── Enums ──────────────────────────────────────────────────────────── @@ -507,6 +507,13 @@ pub struct SideChatSource { /// available. Once that turn later becomes historical, it is still referenced /// by this same identifier. pub turn_id: String, + /// Optional immutable selected-text snapshot to carry into the created side + /// chat's origin. + /// + /// When present, the host MUST snapshot and preserve this exact selection when + /// it accepts `createChat`; later source-turn deltas do not alter it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selection: Option, } /// Creates a new chat within a session. @@ -530,7 +537,10 @@ pub struct CreateChatParams { /// turns. Side chats also carry a stable `turnId`, which the host resolves /// against the source chat's current active turn or retained history. If it /// resolves to the active turn, the host snapshots the currently available - /// partial response when accepting `createChat`. + /// partial response when accepting `createChat`. When + /// `source.kind === "sideChat"` and `source.selection` is present, the host + /// also snapshots and preserves that exact selected text in the created chat's + /// origin; any `responsePartId` there is provenance only, not a live range. #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, /// Initial working-directory subset for this chat. Every entry MUST be diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index b063a7c5a..89c319f74 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -1147,6 +1147,26 @@ pub struct ChatSummary { pub primary_working_directory: Option, } +/// Immutable selected-text snapshot captured when a side chat is created. +/// +/// The host records this exact text when it accepts `createChat`; later changes +/// to the source chat do not alter it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SideChatSelection { + /// Exact selected-text snapshot captured at `createChat` acceptance. + /// + /// MUST be non-empty. + pub text: String, + /// Optional provenance for the response part that contained {@link text} when + /// the host took the snapshot. + /// + /// Advisory only: this is not a live range or offset and MUST NOT be used to + /// recompute `text`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response_part_id: Option, +} + /// Full state for a single session, loaded when a client subscribes to the session's URI. /// /// Inlines (denormalizes) every {@link SessionMetadata} field directly onto @@ -4243,6 +4263,9 @@ pub enum ChatOrigin { /// Stable source-turn identifier through which context was supplied. #[serde(rename = "turnId")] turn_id: String, + /// Optional immutable selected-text snapshot captured when the side chat was created. + #[serde(default, skip_serializing_if = "Option::is_none")] + selection: Option, }, /// Spawned by a tool call in another chat. #[serde(rename = "tool")] diff --git a/clients/rust/crates/ahp-types/tests/chat_source.rs b/clients/rust/crates/ahp-types/tests/chat_source.rs index 2c5defdb6..38bf4a73e 100644 --- a/clients/rust/crates/ahp-types/tests/chat_source.rs +++ b/clients/rust/crates/ahp-types/tests/chat_source.rs @@ -7,7 +7,7 @@ fn chat_source_routes_by_kind() { ) .expect("decode fork source"); let side_chat = serde_json::from_str::( - r#"{"kind":"sideChat","chat":"ahp-chat:/main","turnId":"turn-active"}"#, + r#"{"kind":"sideChat","chat":"ahp-chat:/main","turnId":"turn-active","selection":{"text":"const value = compute()","responsePartId":"part-7"}}"#, ) .expect("decode side chat source"); @@ -23,6 +23,9 @@ fn chat_source_routes_by_kind() { ChatSource::SideChat(value) => { assert_eq!(value.chat, "ahp-chat:/main"); assert_eq!(value.turn_id, "turn-active"); + let selection = value.selection.expect("selection should decode"); + assert_eq!(selection.text, "const value = compute()"); + assert_eq!(selection.response_part_id.as_deref(), Some("part-7")); } other => panic!("expected sideChat variant, got {other:?}"), } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index a3e00f6cf..00f327321 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -125,19 +125,25 @@ public struct SideChatSource: Codable, Sendable { /// available. Once that turn later becomes historical, it is still referenced /// by this same identifier. public var turnId: String + /// Optional immutable selected-text snapshot to carry into the created side + /// chat's origin. + public var selection: SideChatSelection? private enum CodingKeys: String, CodingKey { case kind case chat case turnId + case selection } public init( chat: URI, - turnId: String + turnId: String, + selection: SideChatSelection? = nil ) { self.chat = chat self.turnId = turnId + self.selection = selection } public init(from decoder: Decoder) throws { @@ -148,6 +154,7 @@ public struct SideChatSource: Codable, Sendable { } self.chat = try container.decode(URI.self, forKey: .chat) self.turnId = try container.decode(String.self, forKey: .turnId) + self.selection = try container.decodeIfPresent(SideChatSelection.self, forKey: .selection) } public func encode(to encoder: Encoder) throws { @@ -155,6 +162,7 @@ public struct SideChatSource: Codable, Sendable { try container.encode(ChatSourceKind.sideChat, forKey: .kind) try container.encode(chat, forKey: .chat) try container.encode(turnId, forKey: .turnId) + try container.encodeIfPresent(selection, forKey: .selection) } } @@ -563,7 +571,10 @@ public struct CreateChatParams: Codable, Sendable { /// turns. Side chats also carry a stable `turnId`, which the host resolves /// against the source chat's current active turn or retained history. If it /// resolves to the active turn, the host snapshots the currently available - /// partial response when accepting `createChat`. + /// partial response when accepting `createChat`. When + /// `source.kind === "sideChat"` and `source.selection` is present, the host + /// also snapshots and preserves that exact selected text in the created chat's + /// origin; any `responsePartId` there is provenance only, not a live range. public var source: ChatSource? /// Initial working-directory subset for this chat. Every entry MUST be /// present in the owning session's `workingDirectories`; the server MUST diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index ae060a043..02f3979c0 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -1081,6 +1081,27 @@ public struct ChatSummary: Codable, Sendable { } } +public struct SideChatSelection: Codable, Sendable { + /// Exact selected-text snapshot captured at `createChat` acceptance. + /// + /// MUST be non-empty. + public var text: String + /// Optional provenance for the response part that contained {@link text} when + /// the host took the snapshot. + /// + /// Advisory only: this is not a live range or offset and MUST NOT be used to + /// recompute `text`. + public var responsePartId: String? + + public init( + text: String, + responsePartId: String? = nil + ) { + self.text = text + self.responsePartId = responsePartId + } +} + public struct SessionState: Codable, Sendable { /// Agent provider ID public var provider: String @@ -5235,11 +5256,13 @@ public struct ChatOriginSideChat: Codable, Sendable { public var kind: ChatOriginKind public var chat: String public var turnId: String + public var selection: SideChatSelection? - public init(kind: ChatOriginKind = .sideChat, chat: String, turnId: String) { + public init(kind: ChatOriginKind = .sideChat, chat: String, turnId: String, selection: SideChatSelection? = nil) { self.kind = kind self.chat = chat self.turnId = turnId + self.selection = selection } } diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ChatSourceTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ChatSourceTests.swift index 16b12f97a..9694b6a79 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ChatSourceTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ChatSourceTests.swift @@ -12,7 +12,7 @@ final class ChatSourceTests: XCTestCase { ) let sideChat = try decoder.decode( ChatSource.self, - from: Data(#"{"kind":"sideChat","chat":"ahp-chat:/main","turnId":"turn-active"}"#.utf8) + from: Data(#"{"kind":"sideChat","chat":"ahp-chat:/main","turnId":"turn-active","selection":{"text":"const value = compute()","responsePartId":"part-7"}}"#.utf8) ) switch fork { @@ -27,6 +27,8 @@ final class ChatSourceTests: XCTestCase { case .sideChat(let value): XCTAssertEqual(value.kind, .sideChat) XCTAssertEqual(value.turnId, "turn-active") + XCTAssertEqual(value.selection?.text, "const value = compute()") + XCTAssertEqual(value.selection?.responsePartId, "part-7") default: XCTFail("Expected sideChat variant") } @@ -53,7 +55,11 @@ final class ChatSourceTests: XCTestCase { let encoder = JSONEncoder() let fork = ForkChatSource(chat: "ahp-chat:/main", turnId: "turn-12") - let sideChat = SideChatSource(chat: "ahp-chat:/main", turnId: "turn-active") + let sideChat = SideChatSource( + chat: "ahp-chat:/main", + turnId: "turn-active", + selection: SideChatSelection(text: "const value = compute()", responsePartId: "part-7") + ) XCTAssertEqual(fork.kind, .fork) XCTAssertEqual(sideChat.kind, .sideChat) @@ -67,5 +73,7 @@ final class ChatSourceTests: XCTestCase { XCTAssertEqual(sideChatBranch?["kind"] as? String, "sideChat") XCTAssertEqual(forkUnion?["kind"] as? String, "fork") XCTAssertEqual(sideChatUnion?["kind"] as? String, "sideChat") + XCTAssertEqual((sideChatBranch?["selection"] as? [String: Any])?["text"] as? String, "const value = compute()") + XCTAssertEqual((sideChatUnion?["selection"] as? [String: Any])?["responsePartId"] as? String, "part-7") } } diff --git a/docs/.changes/20260723-side-chat-selection.json b/docs/.changes/20260723-side-chat-selection.json new file mode 100644 index 000000000..b84a95187 --- /dev/null +++ b/docs/.changes/20260723-side-chat-selection.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "Side-chat sources and origins can now carry an immutable selected-text snapshot with optional response-part provenance." +} diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index 843fd7319..b6d466a1f 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -164,11 +164,14 @@ ChatState { Fork and side-chat creation both reference source turns by stable identifiers. Both source forms are fully discriminated — `{ kind: 'fork', chat, turnId }` -and `{ kind: 'sideChat', chat, turnId }`. For side chats, hosts resolve -`turnId` against either `activeTurn` or historical `turns` at creation time. If it names -the active turn, the host snapshots the currently available response there, -which preserves `/btw`-style side chats even though that same turn later moves -into `turns` when it completes. +and `{ kind: 'sideChat', chat, turnId, selection? }`. For side chats, hosts +resolve `turnId` against either `activeTurn` or historical `turns` at creation +time. If it names the active turn, the host snapshots the currently available +response there, which preserves `/btw`-style side chats even though that same +turn later moves into `turns` when it completes. When `selection` is present, +the host also snapshots that exact selected text (which MUST be non-empty) into +the created chat's `origin`; `responsePartId` there is advisory provenance, not +a range. The sections below — turns, response parts, tool calls, pending messages, and input requests — describe the contents of `ChatState`. @@ -671,7 +674,9 @@ Forked chats (those whose `source.kind` is `"fork"`) inherit the source chat's `workingDirectories` and primary, so both fields are ignored for forks. Side chats can still choose their own subset and primary, and they still reference their source with a stable `turnId` whether that turn -was active or historical when the side chat was created. +was active or historical when the side chat was created. They MAY also retain a +selected-text snapshot in `origin.selection`; that snapshot is fixed when the +host accepts `createChat` and does not follow later edits to the source chat. #### Managing the subset after creation diff --git a/docs/proposals/multi-chat.md b/docs/proposals/multi-chat.md index 81a19cad7..7babbe486 100644 --- a/docs/proposals/multi-chat.md +++ b/docs/proposals/multi-chat.md @@ -432,11 +432,14 @@ then diverges on its own. Both chats keep sharing the session's context. A **fork** uses `{ kind: "fork", chat, turnId }` to copy visible history through a completed source turn into a new chat. A **side chat** uses -`{ kind: "sideChat", chat, turnId }`. The host resolves that stable `turnId` -against either a completed turn or the parent's current active turn at creation -time, but the wire does not snapshot which lifecycle slot held it. If the id -names the active turn, the host snapshots whatever response is available at that -moment. This preserves `/btw`-style side questions without copying the parent's turns +`{ kind: "sideChat", chat, turnId, selection? }`. The host resolves that +stable `turnId` against either a completed turn or the parent's current active +turn at creation time, but the wire does not snapshot which lifecycle slot held +it. If the id names the active turn, the host snapshots whatever response is +available at that moment. When `selection` is present, the host also records an +immutable `{ text, responsePartId? }` snapshot of the user's exact selected +text; `text` must be non-empty and `responsePartId` is provenance only, not a +range. This preserves `/btw`-style side questions without copying the parent's turns into its own transcript. It is a focused, independent conversation that can later be pulled back into the main chat as a bounded chat attachment. The distinction keeps the side transcript clean while diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 8d5bd048c..8afdf97e9 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -64,7 +64,9 @@ Clients MAY periodically sync their local input state into the draft by dispatch - an `initialMessage` to start the first turn immediately — carrying its own [`model`](/reference/chat#message) / [`agent`](/reference/chat#message) selection — and - a `source` of type [`ChatSource`](/reference/chat#chatsource), either `{ kind: "fork", chat, turnId }` or `{ kind: "sideChat", chat, turnId }`, - selecting a specific source turn. + selecting a specific source turn. Side-chat sources MAY also carry + `selection: { text, responsePartId? }`, an immutable selected-text snapshot + captured when the host accepts `createChat`. The server allocates the chat URI and adds the chat to the session's catalog (`session/chatAdded` on the session channel) before returning. @@ -85,6 +87,13 @@ retained `turns` as needed. This keeps `/btw`-style side chats from the currently active turn working even though that same turn later moves into historical `turns` when it completes. +When `source.kind` is `"sideChat"` and `source.selection` is present, the host +MUST snapshot that exact `selection.text` when it accepts `createChat`; it MUST +be non-empty. Later source-turn edits or streaming deltas do not retroactively +change the stored snapshot. `selection.responsePartId`, when present, is +advisory provenance naming the response part that contained the text at snapshot +time; it is **not** a live range, offset, or patch anchor. + Forks and side chats use the source differently: - A **fork** copies source history through the referenced turn into the new @@ -96,8 +105,9 @@ Forks and side chats use the source differently: source chat's retained history plus the active turn's current user message and whatever assistant response parts are already available when accepting `createChat`; later source-turn deltas do not retroactively change the side - chat's starting context. An `initialMessage`, when supplied, becomes the side - chat's first visible turn. + chat's starting context. If `source.selection` is present, the host also + snapshots that exact selected text into the created chat's origin. An + `initialMessage`, when supplied, becomes the side chat's first visible turn. ### Origin @@ -107,7 +117,7 @@ Each chat advertises how it came into existence via [`ChatOrigin`](/reference/ch |---|---| | `user` | User created the chat explicitly (e.g. via the host UI). | | `fork` | Forked from an existing chat at a specific completed turn — payload references the source chat URI and stable source `turnId`. | -| `sideChat` | Created as an independent side conversation using context through a specific source turn — payload references the source chat URI and stable source `turnId`, which may have been active or historical when the chat was created. | +| `sideChat` | Created as an independent side conversation using context through a specific source turn — payload references the source chat URI and stable source `turnId`, which may have been active or historical when the chat was created, and MAY retain an immutable `selection` snapshot captured at create acceptance. | | `tool` | Spawned by a tool call running in another chat — payload references the source chat URI and tool call id (e.g. a sub-agent delegation). | Clients MAY use the origin to render contextual UI (parent indicators, fork markers, "spawned by tool" badges), but origin is **not** a hierarchy — every chat is equally addressable. diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 3ee33a3cc..056f1138e 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -4625,6 +4625,23 @@ "modifiedAt" ] }, + "SideChatSelection": { + "type": "object", + "description": "Immutable selected-text snapshot captured when a side chat is created.\n\nThe host records this exact text when it accepts `createChat`; later changes\nto the source chat do not alter it.", + "properties": { + "text": { + "type": "string", + "description": "Exact selected-text snapshot captured at `createChat` acceptance.\n\nMUST be non-empty." + }, + "responsePartId": { + "type": "string", + "description": "Optional provenance for the response part that contained {@link text} when\nthe host took the snapshot.\n\nAdvisory only: this is not a live range or offset and MUST NOT be used to\nrecompute `text`." + } + }, + "required": [ + "text" + ] + }, "PendingMessage": { "type": "object", "description": "A message queued for future delivery to the agent.\n\nSteering messages are injected into the current turn mid-flight.\nQueued messages are automatically started as new turns after the\ncurrent turn naturally finishes.", @@ -7279,6 +7296,9 @@ }, "turnId": { "type": "string" + }, + "selection": { + "$ref": "#/$defs/SideChatSelection" } }, "required": [ @@ -7307,7 +7327,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork and side-chat origins both carry a stable top-level `turnId` alongside\ntheir discriminated `kind` value instead of snapshotting whether that turn\nwas active or historical at creation time. Consumers resolve the identifier\nagainst the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork and side-chat origins both carry a stable top-level `turnId` alongside\ntheir discriminated `kind` value instead of snapshotting whether that turn\nwas active or historical at creation time. Consumers resolve the identifier\nagainst the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`. Side-chat origins MAY also retain an immutable\n{@link SideChatSelection | selected-text snapshot} captured at acceptance\ntime; any `responsePartId` there is provenance only, not a range.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInputQuestion": { "oneOf": [ diff --git a/schema/commands.schema.json b/schema/commands.schema.json index ad54eba94..955f0d761 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -1138,6 +1138,10 @@ "turnId": { "type": "string", "description": "Stable source-turn identifier in the source chat.\n\nHosts resolve this id against the source chat's current `activeTurn` or its\nretained `turns` when accepting `createChat`. If it names the current\nactive turn, the host snapshots the source chat's retained history plus\nthat turn's current user message and any partial assistant response already\navailable. Once that turn later becomes historical, it is still referenced\nby this same identifier." + }, + "selection": { + "$ref": "#/$defs/SideChatSelection", + "description": "Optional immutable selected-text snapshot to carry into the created side\nchat's origin.\n\nWhen present, the host MUST snapshot and preserve this exact selection when\nit accepts `createChat`; later source-turn deltas do not alter it." } }, "required": [ @@ -1164,7 +1168,7 @@ }, "source": { "$ref": "#/$defs/ChatSource", - "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and `kind: \"sideChat\"` when the\nselected agent advertises `capabilities.multipleChats.sideChat`. Both\nsource forms carry a stable top-level `turnId`. Forks target completed\nturns. Side chats also carry a stable `turnId`, which the host resolves\nagainst the source chat's current active turn or retained history. If it\nresolves to the active turn, the host snapshots the currently available\npartial response when accepting `createChat`." + "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and `kind: \"sideChat\"` when the\nselected agent advertises `capabilities.multipleChats.sideChat`. Both\nsource forms carry a stable top-level `turnId`. Forks target completed\nturns. Side chats also carry a stable `turnId`, which the host resolves\nagainst the source chat's current active turn or retained history. If it\nresolves to the active turn, the host snapshots the currently available\npartial response when accepting `createChat`. When\n`source.kind === \"sideChat\"` and `source.selection` is present, the host\nalso snapshots and preserves that exact selected text in the created chat's\norigin; any `responsePartId` there is provenance only, not a live range." }, "workingDirectories": { "type": "array", @@ -3796,6 +3800,23 @@ "modifiedAt" ] }, + "SideChatSelection": { + "type": "object", + "description": "Immutable selected-text snapshot captured when a side chat is created.\n\nThe host records this exact text when it accepts `createChat`; later changes\nto the source chat do not alter it.", + "properties": { + "text": { + "type": "string", + "description": "Exact selected-text snapshot captured at `createChat` acceptance.\n\nMUST be non-empty." + }, + "responsePartId": { + "type": "string", + "description": "Optional provenance for the response part that contained {@link text} when\nthe host took the snapshot.\n\nAdvisory only: this is not a live range or offset and MUST NOT be used to\nrecompute `text`." + } + }, + "required": [ + "text" + ] + }, "PendingMessage": { "type": "object", "description": "A message queued for future delivery to the agent.\n\nSteering messages are injected into the current turn mid-flight.\nQueued messages are automatically started as new turns after the\ncurrent turn naturally finishes.", @@ -9002,6 +9023,9 @@ }, "turnId": { "type": "string" + }, + "selection": { + "$ref": "#/$defs/SideChatSelection" } }, "required": [ @@ -9030,7 +9054,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork and side-chat origins both carry a stable top-level `turnId` alongside\ntheir discriminated `kind` value instead of snapshotting whether that turn\nwas active or historical at creation time. Consumers resolve the identifier\nagainst the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork and side-chat origins both carry a stable top-level `turnId` alongside\ntheir discriminated `kind` value instead of snapshotting whether that turn\nwas active or historical at creation time. Consumers resolve the identifier\nagainst the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`. Side-chat origins MAY also retain an immutable\n{@link SideChatSelection | selected-text snapshot} captured at acceptance\ntime; any `responsePartId` there is provenance only, not a range.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInteractivity": { "enum": [ diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 2f9d24776..abdecb538 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -2528,6 +2528,23 @@ "modifiedAt" ] }, + "SideChatSelection": { + "type": "object", + "description": "Immutable selected-text snapshot captured when a side chat is created.\n\nThe host records this exact text when it accepts `createChat`; later changes\nto the source chat do not alter it.", + "properties": { + "text": { + "type": "string", + "description": "Exact selected-text snapshot captured at `createChat` acceptance.\n\nMUST be non-empty." + }, + "responsePartId": { + "type": "string", + "description": "Optional provenance for the response part that contained {@link text} when\nthe host took the snapshot.\n\nAdvisory only: this is not a live range or offset and MUST NOT be used to\nrecompute `text`." + } + }, + "required": [ + "text" + ] + }, "PendingMessage": { "type": "object", "description": "A message queued for future delivery to the agent.\n\nSteering messages are injected into the current turn mid-flight.\nQueued messages are automatically started as new turns after the\ncurrent turn naturally finishes.", @@ -6122,6 +6139,10 @@ "turnId": { "type": "string", "description": "Stable source-turn identifier in the source chat.\n\nHosts resolve this id against the source chat's current `activeTurn` or its\nretained `turns` when accepting `createChat`. If it names the current\nactive turn, the host snapshots the source chat's retained history plus\nthat turn's current user message and any partial assistant response already\navailable. Once that turn later becomes historical, it is still referenced\nby this same identifier." + }, + "selection": { + "$ref": "#/$defs/SideChatSelection", + "description": "Optional immutable selected-text snapshot to carry into the created side\nchat's origin.\n\nWhen present, the host MUST snapshot and preserve this exact selection when\nit accepts `createChat`; later source-turn deltas do not alter it." } }, "required": [ @@ -6148,7 +6169,7 @@ }, "source": { "$ref": "#/$defs/ChatSource", - "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and `kind: \"sideChat\"` when the\nselected agent advertises `capabilities.multipleChats.sideChat`. Both\nsource forms carry a stable top-level `turnId`. Forks target completed\nturns. Side chats also carry a stable `turnId`, which the host resolves\nagainst the source chat's current active turn or retained history. If it\nresolves to the active turn, the host snapshots the currently available\npartial response when accepting `createChat`." + "description": "Optional source chat and source turn.\n\nThe source chat MUST belong to this session. Clients MUST only request\n`kind: \"fork\"` when the selected agent advertises\n`capabilities.multipleChats.fork`, and `kind: \"sideChat\"` when the\nselected agent advertises `capabilities.multipleChats.sideChat`. Both\nsource forms carry a stable top-level `turnId`. Forks target completed\nturns. Side chats also carry a stable `turnId`, which the host resolves\nagainst the source chat's current active turn or retained history. If it\nresolves to the active turn, the host snapshots the currently available\npartial response when accepting `createChat`. When\n`source.kind === \"sideChat\"` and `source.selection` is present, the host\nalso snapshots and preserves that exact selected text in the created chat's\norigin; any `responsePartId` there is provenance only, not a live range." }, "workingDirectories": { "type": "array", @@ -6600,6 +6621,9 @@ }, "turnId": { "type": "string" + }, + "selection": { + "$ref": "#/$defs/SideChatSelection" } }, "required": [ @@ -6628,7 +6652,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork and side-chat origins both carry a stable top-level `turnId` alongside\ntheir discriminated `kind` value instead of snapshotting whether that turn\nwas active or historical at creation time. Consumers resolve the identifier\nagainst the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork and side-chat origins both carry a stable top-level `turnId` alongside\ntheir discriminated `kind` value instead of snapshotting whether that turn\nwas active or historical at creation time. Consumers resolve the identifier\nagainst the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`. Side-chat origins MAY also retain an immutable\n{@link SideChatSelection | selected-text snapshot} captured at acceptance\ntime; any `responsePartId` there is provenance only, not a range.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInteractivity": { "enum": [ diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index a85edffd1..831740384 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -2691,6 +2691,23 @@ "modifiedAt" ] }, + "SideChatSelection": { + "type": "object", + "description": "Immutable selected-text snapshot captured when a side chat is created.\n\nThe host records this exact text when it accepts `createChat`; later changes\nto the source chat do not alter it.", + "properties": { + "text": { + "type": "string", + "description": "Exact selected-text snapshot captured at `createChat` acceptance.\n\nMUST be non-empty." + }, + "responsePartId": { + "type": "string", + "description": "Optional provenance for the response part that contained {@link text} when\nthe host took the snapshot.\n\nAdvisory only: this is not a live range or offset and MUST NOT be used to\nrecompute `text`." + } + }, + "required": [ + "text" + ] + }, "PendingMessage": { "type": "object", "description": "A message queued for future delivery to the agent.\n\nSteering messages are injected into the current turn mid-flight.\nQueued messages are automatically started as new turns after the\ncurrent turn naturally finishes.", @@ -5414,6 +5431,9 @@ }, "turnId": { "type": "string" + }, + "selection": { + "$ref": "#/$defs/SideChatSelection" } }, "required": [ @@ -5442,7 +5462,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork and side-chat origins both carry a stable top-level `turnId` alongside\ntheir discriminated `kind` value instead of snapshotting whether that turn\nwas active or historical at creation time. Consumers resolve the identifier\nagainst the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork and side-chat origins both carry a stable top-level `turnId` alongside\ntheir discriminated `kind` value instead of snapshotting whether that turn\nwas active or historical at creation time. Consumers resolve the identifier\nagainst the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`. Side-chat origins MAY also retain an immutable\n{@link SideChatSelection | selected-text snapshot} captured at acceptance\ntime; any `responsePartId` there is provenance only, not a range.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInteractivity": { "enum": [ diff --git a/schema/state.schema.json b/schema/state.schema.json index e2685fe13..a2f24c9bc 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -2439,6 +2439,23 @@ "modifiedAt" ] }, + "SideChatSelection": { + "type": "object", + "description": "Immutable selected-text snapshot captured when a side chat is created.\n\nThe host records this exact text when it accepts `createChat`; later changes\nto the source chat do not alter it.", + "properties": { + "text": { + "type": "string", + "description": "Exact selected-text snapshot captured at `createChat` acceptance.\n\nMUST be non-empty." + }, + "responsePartId": { + "type": "string", + "description": "Optional provenance for the response part that contained {@link text} when\nthe host took the snapshot.\n\nAdvisory only: this is not a live range or offset and MUST NOT be used to\nrecompute `text`." + } + }, + "required": [ + "text" + ] + }, "PendingMessage": { "type": "object", "description": "A message queued for future delivery to the agent.\n\nSteering messages are injected into the current turn mid-flight.\nQueued messages are automatically started as new turns after the\ncurrent turn naturally finishes.", @@ -5093,6 +5110,9 @@ }, "turnId": { "type": "string" + }, + "selection": { + "$ref": "#/$defs/SideChatSelection" } }, "required": [ @@ -5121,7 +5141,7 @@ ] } ], - "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork and side-chat origins both carry a stable top-level `turnId` alongside\ntheir discriminated `kind` value instead of snapshotting whether that turn\nwas active or historical at creation time. Consumers resolve the identifier\nagainst the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." + "description": "How a chat came into existence. Clients MAY use it to render\ncontextual UI (parent indicators, fork markers, \"spawned by tool\" badges).\n\nFork and side-chat origins both carry a stable top-level `turnId` alongside\ntheir discriminated `kind` value instead of snapshotting whether that turn\nwas active or historical at creation time. Consumers resolve the identifier\nagainst the\nsource chat's current `activeTurn` or retained `turns` as needed.\n\nWhen a host accepts side-chat creation from the source chat's current active\nturn, it snapshots the retained history plus that turn's current user\nmessage and any partial assistant response already available. Later\nsource-turn deltas do not retroactively change the created side chat's\nstarting context, and once the source turn completes it is still referenced\nby the same `turnId`. Side-chat origins MAY also retain an immutable\n{@link SideChatSelection | selected-text snapshot} captured at acceptance\ntime; any `responsePartId` there is provenance only, not a range.\n\nThe `tool` variant records a tool-spawned worker from the worker's side: its\n`chat`/`toolCallId` identify the spawning tool call in the parent chat. This\nis the canonical record of the spawn relationship. The same edge is surfaced\nfrom the parent's side by {@link ToolResultSubagentContent}, whose `resource`\nis this chat's URI; hosts MUST keep the two consistent." }, "ChatInputQuestion": { "oneOf": [ diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 8241234f7..b80b1e635 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -723,6 +723,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'ChangesSummary' }, { name: 'ChatState' }, { name: 'ChatSummary' }, + { name: 'SideChatSelection' }, { name: 'PendingMessage' }, { name: 'ProjectInfo' }, { name: 'SessionConfigPropertySchema' }, @@ -1080,6 +1081,7 @@ type ChatSideChatOrigin struct { \tKind ChatOriginKind \`json:"kind"\` \tChat URI \`json:"chat"\` \tTurnId string \`json:"turnId"\` +\tSelection *SideChatSelection \`json:"selection,omitempty"\` } func (*ChatSideChatOrigin) isChatOrigin() {} diff --git a/scripts/generate-json-schema.test.ts b/scripts/generate-json-schema.test.ts index 6fdf5d0ae..99b8ec73c 100644 --- a/scripts/generate-json-schema.test.ts +++ b/scripts/generate-json-schema.test.ts @@ -196,6 +196,7 @@ describe('generated JSON schemas', () => { assert.deepEqual(branchByKind.get('fork')?.turnId?.type, 'string'); assert.deepEqual(branchByKind.get('sideChat')?.turnId?.type, 'string'); + assert.deepEqual(branchByKind.get('sideChat')?.selection?.$ref, '#/$defs/SideChatSelection'); const chatSource = defs.ChatSource; if (chatSource) { @@ -203,8 +204,13 @@ describe('generated JSON schemas', () => { assert.deepEqual(defs.ForkChatSource?.properties?.turnId?.type, 'string'); assert.deepEqual(defs.SideChatSource?.properties?.kind?.const, 'sideChat'); assert.deepEqual(defs.SideChatSource?.properties?.turnId?.type, 'string'); + assert.deepEqual(defs.SideChatSource?.properties?.selection?.$ref, '#/$defs/SideChatSelection'); assert.equal(defs.SideChatSource?.properties?.turn, undefined); } + + assert.deepEqual(defs.SideChatSelection?.required, ['text']); + assert.deepEqual(defs.SideChatSelection?.properties?.text?.type, 'string'); + assert.deepEqual(defs.SideChatSelection?.properties?.responsePartId?.type, 'string'); }); it('accepts valid discriminated ChatSource payloads and rejects missing or unknown kinds', () => { @@ -227,6 +233,10 @@ describe('generated JSON schemas', () => { kind: 'sideChat', chat: 'ahp-chat:/source', turnId: 'turn-1', + selection: { + text: 'selected text', + responsePartId: 'part-1', + }, }), true, ); @@ -237,6 +247,15 @@ describe('generated JSON schemas', () => { }), false, ); + assert.equal( + schemaAccepts(schema, chatSource, { + kind: 'sideChat', + chat: 'ahp-chat:/source', + turnId: 'turn-1', + selection: {}, + }), + false, + ); assert.equal( schemaAccepts(schema, chatSource, { kind: 'unknown', diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 964557418..aeb313501 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -597,6 +597,20 @@ function generateFixedChatSourceBranchKotlin( doc: string, turnIdDoc: string, ): string { + const sideChatSelectionProps = name === 'SideChatSource' + ? `${emitKDoc('Optional immutable selected-text snapshot to carry into the created side\nchat\'s origin.', ' ').join('\n')} + val selection: SideChatSelection? = null, +` + : ''; + const sideChatSelectionWireProps = name === 'SideChatSource' + ? ' val selection: SideChatSelection? = null,\n' + : ''; + const sideChatSelectionDecodeArg = name === 'SideChatSource' + ? ', selection = wire.selection' + : ''; + const sideChatSelectionEncodeArg = name === 'SideChatSource' + ? ', selection = value.selection' + : ''; return `${emitKDoc(doc).join('\n')} @Serializable(with = ${name}Serializer::class) data class ${name}( @@ -604,6 +618,7 @@ ${emitKDoc('URI of the existing source chat.', ' ').join('\n')} val chat: URI, ${emitKDoc(turnIdDoc, ' ').join('\n')} val turnId: String, +${sideChatSelectionProps} ) { val kind: ChatSourceKind get() = ChatSourceKind.${kindMember} @@ -614,7 +629,7 @@ private data class ${name}Wire( val kind: ChatSourceKind, val chat: URI, val turnId: String, -) +${sideChatSelectionWireProps}) internal object ${name}Serializer : KSerializer<${name}> { override val descriptor: SerialDescriptor = ${name}Wire.serializer().descriptor @@ -626,7 +641,7 @@ internal object ${name}Serializer : KSerializer<${name}> { if (wire.kind != ChatSourceKind.${kindMember}) { error("Expected ${name} kind ${kindWire}") } - return ${name}(chat = wire.chat, turnId = wire.turnId) + return ${name}(chat = wire.chat, turnId = wire.turnId${sideChatSelectionDecodeArg}) } override fun serialize(encoder: Encoder, value: ${name}) { @@ -634,7 +649,7 @@ internal object ${name}Serializer : KSerializer<${name}> { ?: error("${name} can only be serialized to JSON") val element = output.json.encodeToJsonElement( ${name}Wire.serializer(), - ${name}Wire(kind = ChatSourceKind.${kindMember}, chat = value.chat, turnId = value.turnId), + ${name}Wire(kind = ChatSourceKind.${kindMember}, chat = value.chat, turnId = value.turnId${sideChatSelectionEncodeArg}), ) output.encodeJsonElement(element) } @@ -857,7 +872,7 @@ const STATE_STRUCTS = [ 'MultipleChatsCapability', 'MultipleWorkingDirectoriesCapability', 'SessionModelInfo', 'ModelSelection', 'AgentSelection', 'ConfigPropertySchema', 'ConfigSchema', - 'PendingMessage', 'ChatState', 'ChatSummary', 'SessionState', 'SessionActiveClient', + 'PendingMessage', 'ChatState', 'ChatSummary', 'SideChatSelection', 'SessionState', 'SessionActiveClient', 'SessionChatInputRequest', 'SessionToolConfirmationRequest', 'SessionToolClientExecutionRequest', 'SessionToolAuthenticationRequest', 'SessionSummary', 'ChangesSummary', 'ProjectInfo', 'SessionConfigState', 'Turn', 'ActiveTurn', 'Message', @@ -999,6 +1014,7 @@ data class ChatOriginSideChat( val kind: ChatOriginKind = ChatOriginKind.SIDE_CHAT, val chat: String, val turnId: String, + val selection: SideChatSelection? = null, ) internal object ChatOriginSerializer : KSerializer { diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 47e5f23df..98ea868ed 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -699,6 +699,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'PendingMessage' }, { name: 'ChatState' }, { name: 'ChatSummary' }, + { name: 'SideChatSelection' }, { name: 'SessionState' }, { name: 'SessionActiveClient' }, { name: 'SessionChatInputRequest', omitDiscriminants: true }, @@ -1066,6 +1067,9 @@ pub enum ChatOrigin { /// Stable source-turn identifier through which context was supplied. #[serde(rename = "turnId")] turn_id: String, + /// Optional immutable selected-text snapshot captured when the side chat was created. + #[serde(default, skip_serializing_if = "Option::is_none")] + selection: Option, }, /// Spawned by a tool call in another chat. #[serde(rename = "tool")] @@ -1313,7 +1317,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, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); + lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, Customization, ErrorInfo, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); lines.push(''); // ActionType enum @@ -1474,7 +1478,7 @@ function generateCommandsFile(project: Project): string { lines.push('#[allow(unused_imports)]'); lines.push('use crate::actions::{ActionEnvelope, StateAction};'); lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentSelection, ContentRef, Message, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn};'); + lines.push('use crate::state::{AgentSelection, ContentRef, Message, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, SideChatSelection, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn};'); lines.push(''); lines.push('// ─── Enums ────────────────────────────────────────────────────────────\n'); diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 99e61ed9f..acc4aa626 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -521,6 +521,7 @@ function generateFixedChatSourceBranchSwift( doc: string, turnIdDoc: string, ): string { + const hasSelection = name === 'SideChatSource'; const lines = [ ...doc.split('\n').map(line => emitSwiftDocLine(line)), `public struct ${name}: Codable, Sendable {`, @@ -530,19 +531,28 @@ function generateFixedChatSourceBranchSwift( ' public var chat: URI', ...turnIdDoc.split('\n').map(line => emitSwiftDocLine(line, ' ')), ' public var turnId: String', + ...(hasSelection + ? [ + ...'Optional immutable selected-text snapshot to carry into the created side\nchat\'s origin.'.split('\n').map(line => emitSwiftDocLine(line, ' ')), + ' public var selection: SideChatSelection?', + ] + : []), '', ' private enum CodingKeys: String, CodingKey {', ' case kind', ' case chat', ' case turnId', + ...(hasSelection ? [' case selection'] : []), ' }', '', ' public init(', ' chat: URI,', - ' turnId: String', + ' turnId: String' + (hasSelection ? ',' : ''), + ...(hasSelection ? [' selection: SideChatSelection? = nil'] : []), ' ) {', ' self.chat = chat', ' self.turnId = turnId', + ...(hasSelection ? [' self.selection = selection'] : []), ' }', '', ' public init(from decoder: Decoder) throws {', @@ -553,6 +563,7 @@ function generateFixedChatSourceBranchSwift( ' }', ' self.chat = try container.decode(URI.self, forKey: .chat)', ' self.turnId = try container.decode(String.self, forKey: .turnId)', + ...(hasSelection ? [' self.selection = try container.decodeIfPresent(SideChatSelection.self, forKey: .selection)'] : []), ' }', '', ' public func encode(to encoder: Encoder) throws {', @@ -560,6 +571,7 @@ function generateFixedChatSourceBranchSwift( ` try container.encode(ChatSourceKind.${kindCase}, forKey: .kind)`, ' try container.encode(chat, forKey: .chat)', ' try container.encode(turnId, forKey: .turnId)', + ...(hasSelection ? [' try container.encodeIfPresent(selection, forKey: .selection)'] : []), ' }', '}', ]; @@ -607,7 +619,7 @@ const STATE_STRUCTS = [ 'MultipleChatsCapability', 'MultipleWorkingDirectoriesCapability', 'SessionModelInfo', 'ModelSelection', 'AgentSelection', 'ConfigPropertySchema', 'ConfigSchema', - 'PendingMessage', 'ChatState', 'ChatSummary', 'SessionState', 'SessionActiveClient', + 'PendingMessage', 'ChatState', 'ChatSummary', 'SideChatSelection', 'SessionState', 'SessionActiveClient', 'SessionChatInputRequest', 'SessionToolConfirmationRequest', 'SessionToolClientExecutionRequest', 'SessionToolAuthenticationRequest', 'SessionSummary', 'ChangesSummary', 'ProjectInfo', 'SessionConfigState', 'Turn', 'ActiveTurn', 'Message', @@ -1045,11 +1057,13 @@ public struct ChatOriginSideChat: Codable, Sendable { public var kind: ChatOriginKind public var chat: String public var turnId: String + public var selection: SideChatSelection? - public init(kind: ChatOriginKind = .sideChat, chat: String, turnId: String) { + public init(kind: ChatOriginKind = .sideChat, chat: String, turnId: String, selection: SideChatSelection? = nil) { self.kind = kind self.chat = chat self.turnId = turnId + self.selection = selection } } diff --git a/types/channels-chat/commands.ts b/types/channels-chat/commands.ts index 325a167a3..42da6f566 100644 --- a/types/channels-chat/commands.ts +++ b/types/channels-chat/commands.ts @@ -6,7 +6,7 @@ import type { URI } from '../common/state.js'; import type { BaseParams } from '../common/commands.js'; -import type { Message } from './state.js'; +import type { Message, SideChatSelection } from './state.js'; // ─── createChat ────────────────────────────────────────────────────────────── @@ -56,6 +56,14 @@ export interface SideChatSource { * by this same identifier. */ turnId: string; + /** + * Optional immutable selected-text snapshot to carry into the created side + * chat's origin. + * + * When present, the host MUST snapshot and preserve this exact selection when + * it accepts `createChat`; later source-turn deltas do not alter it. + */ + selection?: SideChatSelection; } /** @@ -92,7 +100,10 @@ export interface CreateChatParams extends BaseParams { * turns. Side chats also carry a stable `turnId`, which the host resolves * against the source chat's current active turn or retained history. If it * resolves to the active turn, the host snapshots the currently available - * partial response when accepting `createChat`. + * partial response when accepting `createChat`. When + * `source.kind === "sideChat"` and `source.selection` is present, the host + * also snapshots and preserves that exact selected text in the created chat's + * origin; any `responsePartId` there is provenance only, not a live range. */ source?: ChatSource; /** diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index cc6ac64cf..e08074aa0 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -181,6 +181,31 @@ export const enum ChatOriginKind { Tool = 'tool', } +/** + * Immutable selected-text snapshot captured when a side chat is created. + * + * The host records this exact text when it accepts `createChat`; later changes + * to the source chat do not alter it. + * + * @category Chat State + */ +export interface SideChatSelection { + /** + * Exact selected-text snapshot captured at `createChat` acceptance. + * + * MUST be non-empty. + */ + text: string; + /** + * Optional provenance for the response part that contained {@link text} when + * the host took the snapshot. + * + * Advisory only: this is not a live range or offset and MUST NOT be used to + * recompute `text`. + */ + responsePartId?: string; +} + /** * How a chat came into existence. Clients MAY use it to render * contextual UI (parent indicators, fork markers, "spawned by tool" badges). @@ -196,7 +221,9 @@ export const enum ChatOriginKind { * message and any partial assistant response already available. Later * source-turn deltas do not retroactively change the created side chat's * starting context, and once the source turn completes it is still referenced - * by the same `turnId`. + * by the same `turnId`. Side-chat origins MAY also retain an immutable + * {@link SideChatSelection | selected-text snapshot} captured at acceptance + * time; any `responsePartId` there is provenance only, not a range. * * The `tool` variant records a tool-spawned worker from the worker's side: its * `chat`/`toolCallId` identify the spawning tool call in the parent chat. This @@ -209,7 +236,7 @@ export const enum ChatOriginKind { export type ChatOrigin = | { kind: ChatOriginKind.User } | { kind: ChatOriginKind.Fork; chat: URI; turnId: string } - | { kind: ChatOriginKind.SideChat; chat: URI; turnId: string } + | { kind: ChatOriginKind.SideChat; chat: URI; turnId: string; selection?: SideChatSelection } | { kind: ChatOriginKind.Tool; chat: URI; toolCallId: string }; /** diff --git a/types/index.ts b/types/index.ts index f7e689ed6..fd967ddaf 100644 --- a/types/index.ts +++ b/types/index.ts @@ -26,6 +26,7 @@ export type { ChatState, ChatSummary, ChatOrigin, + SideChatSelection, ChangesSummary, SessionConfigState, Turn, diff --git a/types/test-cases/reducers/256-session-chatadded-preserves-sidechat-selection.json b/types/test-cases/reducers/256-session-chatadded-preserves-sidechat-selection.json new file mode 100644 index 000000000..8cde32d74 --- /dev/null +++ b/types/test-cases/reducers/256-session-chatadded-preserves-sidechat-selection.json @@ -0,0 +1,56 @@ +{ + "description": "session/chatAdded preserves side-chat selection snapshots on chat origins", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [] + }, + "actions": [ + { + "type": "session/chatAdded", + "summary": { + "resource": "ahp-chat:/c1", + "title": "Side Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z", + "origin": { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turnId": "turn-active", + "selection": { + "text": "const value = compute()", + "responsePartId": "part-7" + } + } + } + } + ], + "expected": { + "provider": "copilot", + "title": "Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [ + { + "resource": "ahp-chat:/c1", + "title": "Side Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z", + "origin": { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turnId": "turn-active", + "selection": { + "text": "const value = compute()", + "responsePartId": "part-7" + } + } + } + ] + } +} diff --git a/types/test-cases/round-trips/038-chat-source-side-chat-selection.json b/types/test-cases/round-trips/038-chat-source-side-chat-selection.json new file mode 100644 index 000000000..5f93be8fa --- /dev/null +++ b/types/test-cases/round-trips/038-chat-source-side-chat-selection.json @@ -0,0 +1,26 @@ +{ + "name": "chat-source-side-chat-selection", + "group": "A", + "description": "ChatSource preserves an optional side-chat selected-text snapshot and advisory response-part provenance.", + "type": "ChatSource", + "input": { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turnId": "turn-active", + "selection": { + "text": "const value = compute()", + "responsePartId": "part-7" + } + }, + "acceptableOutputs": [ + { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turnId": "turn-active", + "selection": { + "text": "const value = compute()", + "responsePartId": "part-7" + } + } + ] +} diff --git a/types/test-cases/round-trips/039-side-chat-origin-selection.json b/types/test-cases/round-trips/039-side-chat-origin-selection.json new file mode 100644 index 000000000..8df67ea31 --- /dev/null +++ b/types/test-cases/round-trips/039-side-chat-origin-selection.json @@ -0,0 +1,44 @@ +{ + "name": "side-chat-origin-selection", + "group": "A", + "description": "A session/chatAdded action preserves side-chat selection snapshots alongside the stable source turn identity.", + "type": "StateAction", + "input": { + "type": "session/chatAdded", + "summary": { + "resource": "ahp-chat:/side-selected", + "title": "Side investigation", + "status": 1, + "modifiedAt": "2026-07-23T19:00:00.000Z", + "origin": { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turnId": "turn-active", + "selection": { + "text": "const value = compute()", + "responsePartId": "part-7" + } + } + } + }, + "acceptableOutputs": [ + { + "type": "session/chatAdded", + "summary": { + "resource": "ahp-chat:/side-selected", + "title": "Side investigation", + "status": 1, + "modifiedAt": "2026-07-23T19:00:00.000Z", + "origin": { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turnId": "turn-active", + "selection": { + "text": "const value = compute()", + "responsePartId": "part-7" + } + } + } + } + ] +}