diff --git a/clients/go/ahptypes/chat_source_test.go b/clients/go/ahptypes/chat_source_test.go new file mode 100644 index 00000000..35384128 --- /dev/null +++ b/clients/go/ahptypes/chat_source_test.go @@ -0,0 +1,125 @@ +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","selection":{"text":"const value = compute()","responsePartId":"part-7"}}`), &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) + } + 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) + } + }) +} + +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) + } + }) + } +} + +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", + Selection: &SideChatSelection{ + Text: "const value = compute()", + ResponsePartId: stringPtr("part-7"), + }, + } + + 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) + } + 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 533f9d88..d00d0674 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,14 +384,42 @@ 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 +// 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"` - // Turn ID in the source chat; content up to and including this turn's response is copied + // Completed turn identifier in the source chat. + // + // Content through this turn is copied into the new chat's visible `turns`. TurnId string `json:"turnId"` } +// 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"` + // 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. + 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. type CreateChatParams struct { // Channel URI this command targets. @@ -390,13 +428,26 @@ 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 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`. 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`. 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 // 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 (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}. @@ -408,8 +459,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 chat whose + // `source.kind` is `"fork"` inherits the source chat's primary). PrimaryWorkingDirectory *URI `json:"primaryWorkingDirectory,omitempty"` } @@ -1077,6 +1128,112 @@ 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. +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, ok, err := readDiscriminator(data, "kind") + if err != nil { + return err + } + if !ok { + return missingDiscriminatorError("ChatSource", "kind") + } + 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) + } + 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. @@ -1093,10 +1250,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 @@ -1111,7 +1271,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 233051bb..9b4dddc2 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/roundtrip_fixture_test.go b/clients/go/ahptypes/roundtrip_fixture_test.go index 4f1e363e..834d30ee 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 3f5ba041..d45e74af 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,21 @@ 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. 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"` } // Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability. @@ -1166,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. @@ -1684,6 +1718,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 +4233,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 +4273,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 +4827,15 @@ type ChatForkOrigin struct { func (*ChatForkOrigin) isChatOrigin() {} +type ChatSideChatOrigin struct { + Kind ChatOriginKind `json:"kind"` + Chat URI `json:"chat"` + TurnId string `json:"turnId"` + Selection *SideChatSelection `json:"selection,omitempty"` +} + +func (*ChatSideChatOrigin) isChatOrigin() {} + type ChatToolOrigin struct { Kind ChatOriginKind `json:"kind"` Chat URI `json:"chat"` @@ -4774,6 +4868,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 96ecb2c4..3d63475e 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. */ @@ -103,6 +120,122 @@ 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, + /** + * 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 +} + +@Serializable +private data class SideChatSourceWire( + val kind: ChatSourceKind, + val chat: URI, + val turnId: String, + val selection: SideChatSelection? = null, +) + +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, selection = wire.selection) + } + + 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, selection = value.selection), + ) + output.encodeJsonElement(element) + } +} + @Serializable data class InitializeParams( /** @@ -449,18 +582,6 @@ data class DisposeSessionParams( val channel: String ) -@Serializable -data class ChatForkSource( - /** - * URI of the existing chat to fork from - */ - val chat: String, - /** - * Turn ID in the source chat; content up to and including this turn's response is copied - */ - val turnId: String -) - @Serializable data class CreateChatParams( /** @@ -476,15 +597,28 @@ data class CreateChatParams( */ val initialMessage: Message? = null, /** - * Optional source chat and turn to fork from. - */ - val source: ChatForkSource? = null, + * 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`. 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`. 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, /** * 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 (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}. @@ -498,8 +632,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 chat whose + * `source.kind` is `"fork"` inherits the source chat's primary). */ val primaryWorkingDirectory: String? = null ) @@ -1240,6 +1374,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 1607814e..1cc71bd9 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,24 @@ 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. 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 ) @Serializable @@ -1302,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( /** @@ -2318,6 +2361,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 +4657,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 +4681,14 @@ data class ChatOriginTool( val toolCallId: String, ) +@Serializable +data class ChatOriginSideChat( + val kind: ChatOriginKind = ChatOriginKind.SIDE_CHAT, + val chat: String, + val turnId: String, + val selection: SideChatSelection? = null, +) + internal object ChatOriginSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ChatOrigin") @@ -4598,6 +4699,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 +4710,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 +5183,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 +5213,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 +5226,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/DiscriminatedUnionTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt index d8ec4f59..fbe2e4c4 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,13 @@ 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.SideChatSelection import com.microsoft.agenthostprotocol.generated.StringOrMarkdown import com.microsoft.agenthostprotocol.generated.ToolResultContent import kotlinx.serialization.json.JsonObject @@ -26,6 +33,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 +124,85 @@ 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","selection":{"text":"const value = compute()","responsePartId":"part-7"}}""" + + 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) + assertEquals("const value = compute()", asSideChat.value.selection?.text) + assertEquals("part-7", asSideChat.value.selection?.responsePartId) + + val reEncodedFork = json.encodeToString( + ChatSource.serializer(), + ChatSourceFork(ForkChatSource(chat = "ahp-chat:/main", turnId = "turn-12")), + ) + val reEncodedSideChat = json.encodeToString( + ChatSource.serializer(), + 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", + selection = SideChatSelection( + text = "const value = compute()", + responsePartId = "part-7", + ), + ) + + 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"]) + assertEquals(JsonPrimitive("part-7"), json.parseToJsonElement(encodedSideChatUnion).jsonObject["selection"]?.jsonObject?.get("responsePartId")) + } + + @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/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt index cd2c2393..d11abc8a 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/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index aa2ad448..1ff1b20f 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 cc4e7a4c..6f33ec46 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 ──────────────────────────────────────────────────────────── @@ -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,14 +479,41 @@ pub struct DisposeSessionParams { pub channel: Uri, } -/// Identifies a source chat and turn to fork from. +/// Copies source history through a completed turn into the new chat. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ForkChatSource { + /// URI of the existing source chat. + pub chat: Uri, + /// Completed turn identifier in the source chat. + /// + /// Content through this turn is copied into the new chat's visible `turns`. + pub turn_id: String, +} + +/// 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 ChatForkSource { - /// URI of the existing chat to fork from +pub struct SideChatSource { + /// 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 + /// 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. 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. @@ -489,14 +527,27 @@ 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. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source: 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 + /// `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`. 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 /// 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 (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}. @@ -509,8 +560,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 chat whose + /// `source.kind` is `"fork"` inherits the source chat's primary). #[serde(default, skip_serializing_if = "Option::is_none")] pub primary_working_directory: Option, } @@ -1301,6 +1352,18 @@ pub struct ChangesetOperationFollowUp { pub external: Option, } +// ─── ChatSource Union ───────────────────────────────────────────────── + +/// How a new chat uses a source chat. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum ChatSource { + #[serde(rename = "fork")] + Fork(ForkChatSource), + #[serde(rename = "sideChat")] + SideChat(SideChatSource), +} + // ─── 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 18ca7539..89c319f7 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,23 @@ 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. 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, } /// Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability. @@ -1127,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 @@ -2104,6 +2144,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 { @@ -4164,9 +4251,21 @@ pub enum ChatOrigin { Fork { /// URI of the chat this one was forked from. chat: Uri, - /// Turn the fork was taken from. + /// 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")] + SideChat { + /// URI of the chat that supplied the side-chat context. + chat: Uri, + /// 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")] @@ -4365,6 +4464,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/chat_source.rs b/clients/rust/crates/ahp-types/tests/chat_source.rs new file mode 100644 index 00000000..38bf4a73 --- /dev/null +++ b/clients/rust/crates/ahp-types/tests/chat_source.rs @@ -0,0 +1,45 @@ +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","selection":{"text":"const value = compute()","responsePartId":"part-7"}}"#, + ) + .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"); + 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:?}"), + } +} + +#[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/rust/crates/ahp-types/tests/roundtrip_corpus.rs b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs index b7549e36..030a4720 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 59f6eb60..00f32732 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" @@ -58,6 +66,106 @@ 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 + /// 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, + selection: SideChatSelection? = nil + ) { + self.chat = chat + self.turnId = turnId + self.selection = selection + } + + 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) + self.selection = try container.decodeIfPresent(SideChatSelection.self, forKey: .selection) + } + + 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) + try container.encodeIfPresent(selection, forKey: .selection) + } +} + public struct InitializeParams: Codable, Sendable { /// Channel URI this command targets. public var channel: String @@ -446,21 +554,6 @@ public struct DisposeSessionParams: Codable, Sendable { } } -public struct ChatForkSource: Codable, Sendable { - /// URI of the existing chat to fork from - public var chat: String - /// Turn ID in the source chat; content up to and including this turn's response is copied - public var turnId: String - - public init( - chat: String, - turnId: String - ) { - self.chat = chat - self.turnId = turnId - } -} - public struct CreateChatParams: Codable, Sendable { /// Channel URI this command targets. public var channel: String @@ -468,13 +561,26 @@ 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 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`. 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`. 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 /// 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 (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}. @@ -486,15 +592,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 chat whose + /// `source.kind` is `"fork"` 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 ) { @@ -1413,6 +1519,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 3c9dacc6..02f3979c 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,28 @@ 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. 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( - fork: Bool? = nil + fork: Bool? = nil, + sideChat: Bool? = nil ) { self.fork = fork + self.sideChat = sideChat } } @@ -1062,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 @@ -2302,6 +2342,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 +5252,24 @@ public struct ChatOriginTool: Codable, Sendable { } } +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, selection: SideChatSelection? = nil) { + self.kind = kind + self.chat = chat + self.turnId = turnId + self.selection = selection + } +} + 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 +5280,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 +5291,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 +5634,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 +5655,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 +5668,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 88463cce..47a9e481 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/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ChatSourceTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ChatSourceTests.swift new file mode 100644 index 00000000..9694b6a7 --- /dev/null +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ChatSourceTests.swift @@ -0,0 +1,79 @@ +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","selection":{"text":"const value = compute()","responsePartId":"part-7"}}"#.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") + XCTAssertEqual(value.selection?.text, "const value = compute()") + XCTAssertEqual(value.selection?.responsePartId, "part-7") + 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) + ) + ) + } + + 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", + selection: SideChatSelection(text: "const value = compute()", responsePartId: "part-7") + ) + + 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") + 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/clients/typescript/test/types-round-trip.test.ts b/clients/typescript/test/types-round-trip.test.ts index c3d2370f..957c009a 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 00000000..5b6faa5a --- /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/.changes/20260722-chat-discriminated-fork-source.json b/docs/.changes/20260722-chat-discriminated-fork-source.json new file mode 100644 index 00000000..d6dc8187 --- /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/20260723-side-chat-selection.json b/docs/.changes/20260723-side-chat-selection.json new file mode 100644 index 00000000..b84a9518 --- /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/.changes/20260723-stable-side-chat-turn-refs.json b/docs/.changes/20260723-stable-side-chat-turn-refs.json new file mode 100644 index 00000000..31bfe6b4 --- /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 8b475348..b6d466a1 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) @@ -162,6 +162,17 @@ 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, 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`. ## Turns @@ -659,8 +670,13 @@ createChat({ }); ``` -Forked chats (those with a `source`) inherit the source chat's -`workingDirectories` and primary; both fields are ignored for them. +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. 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 f7577d6a..7babbe48 100644 --- a/docs/proposals/multi-chat.md +++ b/docs/proposals/multi-chat.md @@ -425,11 +425,35 @@ 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 **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, 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 +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 `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 +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 c326c768..8afdf97e 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,53 @@ 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), either + `{ kind: "fork", chat, turnId }` or `{ kind: "sideChat", chat, turnId }`, + 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. +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 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 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 +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 + 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 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. 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 Each chat advertises how it came into existence via [`ChatOrigin`](/reference/chat#chatorigin): @@ -72,7 +116,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. | +| `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, 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. @@ -81,11 +126,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 eebb5e57..056f1138 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": [ @@ -2717,7 +2723,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 +2737,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. 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." } } }, @@ -3435,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": [ @@ -3453,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": [ @@ -4311,7 +4339,8 @@ "type": "object", "properties": { "tools": { - "type": "string" + "type": "object", + "additionalProperties": {} } }, "description": "Producer serves `sampling/createMessage` via `mcpMethodCall`." @@ -4596,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.", @@ -5393,6 +5439,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": { @@ -5840,7 +5927,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } } }, "required": [ @@ -6353,10 +6443,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -6369,10 +6459,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -6963,7 +7053,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -6975,7 +7068,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -7163,7 +7259,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "user" } }, "required": [ @@ -7174,10 +7270,10 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "fork" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, "turnId": { "type": "string" @@ -7193,11 +7289,33 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "sideChat" }, "chat": { + "$ref": "#/$defs/URI" + }, + "turnId": { "type": "string" }, + "selection": { + "$ref": "#/$defs/SideChatSelection" + } + }, + "required": [ + "kind", + "chat", + "turnId" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "tool" + }, + "chat": { + "$ref": "#/$defs/URI" + }, "toolCallId": { "type": "string" } @@ -7209,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\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": [ @@ -7274,6 +7392,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 231e924a..955f0d76 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -1100,20 +1100,52 @@ "items" ] }, - "ChatForkSource": { + "ForkChatSource": { "type": "object", - "description": "Identifies a source chat and turn to fork from.", + "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 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 identifier in the source chat.\n\nContent through this turn is copied into the new chat's visible `turns`." } }, "required": [ + "kind", + "chat", + "turnId" + ] + }, + "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." + }, + "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": [ + "kind", "chat", "turnId" ] @@ -1135,19 +1167,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 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", "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 (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 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 chat whose\n`source.kind` is `\"fork\"` inherits the source chat's primary)." } }, "required": [ @@ -1289,7 +1321,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -1301,7 +1336,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -1631,10 +1669,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -1647,10 +1685,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -1860,7 +1898,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 +1912,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. 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." } } }, @@ -2578,13 +2620,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": [ @@ -2596,13 +2647,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": [ @@ -3454,7 +3514,8 @@ "type": "object", "properties": { "tools": { - "type": "string" + "type": "object", + "additionalProperties": {} } }, "description": "Producer serves `sampling/createMessage` via `mcpMethodCall`." @@ -3739,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.", @@ -4536,6 +4614,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 +5102,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } } }, "required": [ @@ -5496,10 +5618,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -5512,10 +5634,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -6106,7 +6228,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -6118,7 +6243,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -7040,7 +7168,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } } }, "required": [ @@ -8214,7 +8345,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/ResourceChange" + } } }, "required": [ @@ -8538,10 +8672,24 @@ }, { "$ref": "#/$defs/MessageAnnotationsAttachment" + }, + { + "$ref": "#/$defs/MessageChatAttachment" } ], "description": "An attachment associated with a {@link Message}." }, + "ChatSource": { + "oneOf": [ + { + "$ref": "#/$defs/ForkChatSource" + }, + { + "$ref": "#/$defs/SideChatSource" + } + ], + "description": "Identifies a source chat for a new chat." + }, "TerminalClaim": { "oneOf": [ { @@ -8559,13 +8707,17 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "resource" }, "resource": { - "type": "string" + "$ref": "#/$defs/URI" }, "side": { - "type": "string" + "type": "string", + "enum": [ + "before", + "after" + ] } }, "required": [ @@ -8577,16 +8729,20 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "range" }, "resource": { - "type": "string" + "$ref": "#/$defs/URI" }, "side": { - "type": "string" + "type": "string", + "enum": [ + "before", + "after" + ] }, "range": { - "type": "string" + "$ref": "#/$defs/TextRange" } }, "required": [ @@ -8830,7 +8986,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "user" } }, "required": [ @@ -8841,10 +8997,10 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "fork" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, "turnId": { "type": "string" @@ -8860,11 +9016,33 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "sideChat" }, "chat": { + "$ref": "#/$defs/URI" + }, + "turnId": { "type": "string" }, + "selection": { + "$ref": "#/$defs/SideChatSelection" + } + }, + "required": [ + "kind", + "chat", + "turnId" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "tool" + }, + "chat": { + "$ref": "#/$defs/URI" + }, "toolCallId": { "type": "string" } @@ -8876,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\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 5504cfcb..abdecb53 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": [ @@ -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. 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." } } }, @@ -1344,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": [ @@ -1362,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": [ @@ -2220,7 +2242,8 @@ "type": "object", "properties": { "tools": { - "type": "string" + "type": "object", + "additionalProperties": {} } }, "description": "Producer serves `sampling/createMessage` via `mcpMethodCall`." @@ -2505,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.", @@ -3302,6 +3342,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": { @@ -3749,7 +3830,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } } }, "required": [ @@ -4262,10 +4346,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -4278,10 +4362,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -4872,7 +4956,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -4884,7 +4971,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -6011,20 +6101,52 @@ "items" ] }, - "ChatForkSource": { + "ForkChatSource": { + "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`." + } + }, + "required": [ + "kind", + "chat", + "turnId" + ] + }, + "SideChatSource": { "type": "object", - "description": "Identifies a source chat and turn to fork from.", + "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 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": "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": [ + "kind", "chat", "turnId" ] @@ -6046,19 +6168,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 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", "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 (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 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 chat whose\n`source.kind` is `\"fork\"` inherits the source chat's primary)." } }, "required": [ @@ -6200,7 +6322,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -6212,7 +6337,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -6456,7 +6584,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "user" } }, "required": [ @@ -6467,10 +6595,10 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "fork" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, "turnId": { "type": "string" @@ -6486,11 +6614,33 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "sideChat" }, "chat": { + "$ref": "#/$defs/URI" + }, + "turnId": { "type": "string" }, + "selection": { + "$ref": "#/$defs/SideChatSelection" + } + }, + "required": [ + "kind", + "chat", + "turnId" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "tool" + }, + "chat": { + "$ref": "#/$defs/URI" + }, "toolCallId": { "type": "string" } @@ -6502,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\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": [ @@ -6617,6 +6767,9 @@ }, { "$ref": "#/$defs/MessageAnnotationsAttachment" + }, + { + "$ref": "#/$defs/MessageChatAttachment" } ], "description": "An attachment associated with a {@link Message}." @@ -7108,19 +7261,34 @@ "type": "string", "description": "The kind of completion items being requested." }, + "ChatSource": { + "oneOf": [ + { + "$ref": "#/$defs/ForkChatSource" + }, + { + "$ref": "#/$defs/SideChatSource" + } + ], + "description": "Identifies a source chat for a new chat." + }, "ChangesetOperationTarget": { "oneOf": [ { "type": "object", "properties": { "kind": { - "type": "string" + "const": "resource" }, "resource": { - "type": "string" + "$ref": "#/$defs/URI" }, "side": { - "type": "string" + "type": "string", + "enum": [ + "before", + "after" + ] } }, "required": [ @@ -7132,16 +7300,20 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "range" }, "resource": { - "type": "string" + "$ref": "#/$defs/URI" }, "side": { - "type": "string" + "type": "string", + "enum": [ + "before", + "after" + ] }, "range": { - "type": "string" + "$ref": "#/$defs/TextRange" } }, "required": [ @@ -7992,7 +8164,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } } }, "required": [ @@ -9078,7 +9253,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 2319bd5c..83174038 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": [ @@ -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. 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." } } }, @@ -1507,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": [ @@ -1525,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": [ @@ -2383,7 +2405,8 @@ "type": "object", "properties": { "tools": { - "type": "string" + "type": "object", + "additionalProperties": {} } }, "description": "Producer serves `sampling/createMessage` via `mcpMethodCall`." @@ -2668,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.", @@ -3465,6 +3505,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": { @@ -3912,7 +3993,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } } }, "required": [ @@ -4425,10 +4509,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -4441,10 +4525,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -5035,7 +5119,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -5047,7 +5134,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -5304,7 +5394,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "user" } }, "required": [ @@ -5315,10 +5405,10 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "fork" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, "turnId": { "type": "string" @@ -5334,11 +5424,33 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "sideChat" }, "chat": { + "$ref": "#/$defs/URI" + }, + "turnId": { "type": "string" }, + "selection": { + "$ref": "#/$defs/SideChatSelection" + } + }, + "required": [ + "kind", + "chat", + "turnId" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "tool" + }, + "chat": { + "$ref": "#/$defs/URI" + }, "toolCallId": { "type": "string" } @@ -5350,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\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": [ @@ -5465,6 +5577,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 f82a33c7..a2f24c9b 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": [ @@ -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. 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." } } }, @@ -1255,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": [ @@ -1273,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": [ @@ -2131,7 +2153,8 @@ "type": "object", "properties": { "tools": { - "type": "string" + "type": "object", + "additionalProperties": {} } }, "description": "Producer serves `sampling/createMessage` via `mcpMethodCall`." @@ -2416,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.", @@ -3213,6 +3253,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": { @@ -3660,7 +3741,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } } }, "required": [ @@ -4173,10 +4257,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -4189,10 +4273,10 @@ "type": "object", "properties": { "uri": { - "type": "string" + "$ref": "#/$defs/URI" }, "content": { - "type": "string" + "$ref": "#/$defs/ContentRef" } }, "required": [ @@ -4783,7 +4867,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -4795,7 +4882,10 @@ "type": "object", "properties": { "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -4983,7 +5073,7 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "user" } }, "required": [ @@ -4994,10 +5084,10 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "fork" }, "chat": { - "type": "string" + "$ref": "#/$defs/URI" }, "turnId": { "type": "string" @@ -5013,11 +5103,33 @@ "type": "object", "properties": { "kind": { - "type": "string" + "const": "sideChat" }, "chat": { + "$ref": "#/$defs/URI" + }, + "turnId": { "type": "string" }, + "selection": { + "$ref": "#/$defs/SideChatSelection" + } + }, + "required": [ + "kind", + "chat", + "turnId" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "tool" + }, + "chat": { + "$ref": "#/$defs/URI" + }, "toolCallId": { "type": "string" } @@ -5029,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\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": [ @@ -5094,6 +5206,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 7a5c0e26..b80b1e63 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 { @@ -592,10 +627,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 +655,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'); @@ -677,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' }, @@ -707,6 +754,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 +960,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 +1077,15 @@ type ChatForkOrigin struct { func (*ChatForkOrigin) isChatOrigin() {} +type ChatSideChatOrigin struct { +\tKind ChatOriginKind \`json:"kind"\` +\tChat URI \`json:"chat"\` +\tTurnId string \`json:"turnId"\` +\tSelection *SideChatSelection \`json:"selection,omitempty"\` +} + +func (*ChatSideChatOrigin) isChatOrigin() {} + type ChatToolOrigin struct { \tKind ChatOriginKind \`json:"kind"\` \tChat URI \`json:"chat"\` @@ -1060,6 +1118,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 +1518,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 +1529,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: 'ForkChatSource' }, { name: 'SideChatSource' }, { name: 'CreateChatParams' }, { name: 'DisposeChatParams' }, { name: 'ListSessionsParams' }, { name: 'ListSessionsResult' }, { name: 'ResourceReadParams' }, { name: 'ResourceReadResult' }, { name: 'ResourceWriteParams' }, { name: 'ResourceWriteResult' }, @@ -1500,6 +1564,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. @@ -1593,6 +1667,15 @@ 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(''); + lines.push('// ─── ReconnectResult Union ────────────────────────────────────────────\n'); lines.push(generateDiscriminatedUnion(RECONNECT_RESULT_UNION)); lines.push(''); @@ -1949,6 +2032,7 @@ function checkExhaustiveness(project: Project): void { 'TerminalClaim', 'TerminalContentPart', 'ChatOrigin', + 'ChatSource', 'ChatInputQuestion', 'ChatInputAnswerValue', 'ChatInputAnswer', diff --git a/scripts/generate-json-schema.test.ts b/scripts/generate-json-schema.test.ts index 19c3aaff..99b8ec73 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, () => { @@ -90,6 +170,101 @@ 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']); + }); + + 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>; + 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')?.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) { + 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.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', () => { + const defs = schema.$defs as Record>; + const chatSource = defs.ChatSource; + if (!chatSource) { + return; + } + + assert.equal( + schemaAccepts(schema, chatSource, { + kind: 'fork', + chat: 'ahp-chat:/source', + turnId: 'turn-1', + }), + true, + ); + assert.equal( + schemaAccepts(schema, chatSource, { + kind: 'sideChat', + chat: 'ahp-chat:/source', + turnId: 'turn-1', + selection: { + text: 'selected text', + responsePartId: 'part-1', + }, + }), + true, + ); + assert.equal( + schemaAccepts(schema, chatSource, { + chat: 'ahp-chat:/source', + turnId: 'turn-1', + }), + false, + ); + assert.equal( + schemaAccepts(schema, chatSource, { + kind: 'sideChat', + chat: 'ahp-chat:/source', + turnId: 'turn-1', + selection: {}, + }), + false, + ); + assert.equal( + schemaAccepts(schema, chatSource, { + kind: 'unknown', + chat: 'ahp-chat:/source', + turnId: 'turn-1', + }), + false, + ); + }); }); } }); diff --git a/scripts/generate-json-schema.ts b/scripts/generate-json-schema.ts index e8ebf457..217115a1 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; } @@ -203,7 +205,9 @@ 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: parts.map(p => typeTextToSchema(p, project, _depth + 1)), + }; } if (parts.length === 1 && parts[0] !== cleaned) { return typeTextToSchema(parts[0], project, _depth + 1); @@ -214,20 +218,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 +254,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 +274,8 @@ 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] = typeTextToSchema(fieldType, project); if (!optional) { schema.required!.push(name); } @@ -278,13 +286,6 @@ function inlineObjectToSchema(text: string): 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 { @@ -589,7 +590,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); } } diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 2cf0cd87..aeb31350 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -590,6 +590,72 @@ function generateDataClassFromInterface( return generateKotlinDataClass(name, props); } +function generateFixedChatSourceBranchKotlin( + name: 'ForkChatSource' | 'SideChatSource', + kindMember: 'FORK' | 'SIDE_CHAT', + kindWire: 'fork' | 'sideChat', + 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}( +${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} +} + +@Serializable +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 + + 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${sideChatSelectionDecodeArg}) + } + + 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${sideChatSelectionEncodeArg}), + ) + 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 @@ -806,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', @@ -822,7 +888,7 @@ const STATE_STRUCTS = [ 'ChatInputRequest', 'TextPosition', 'TextRange', 'TextSelection', 'SimpleMessageAttachment', 'MessageEmbeddedResourceAttachment', 'MessageResourceAttachment', - 'MessageAnnotationsAttachment', + 'MessageAnnotationsAttachment', 'MessageChatAttachment', 'MarkdownResponsePart', 'ContentRef', 'ResourceReponsePart', 'ToolCallResponsePart', 'ReasoningResponsePart', 'SystemNotificationResponsePart', 'InputRequestResponsePart', @@ -919,6 +985,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 +1009,14 @@ data class ChatOriginTool( val toolCallId: String, ) +@Serializable +data class ChatOriginSideChat( + val kind: ChatOriginKind = ChatOriginKind.SIDE_CHAT, + val chat: String, + val turnId: String, + val selection: SideChatSelection? = null, +) + internal object ChatOriginSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ChatOrigin") @@ -952,6 +1027,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 +1038,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 +1095,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 +1505,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 +1513,7 @@ const COMMAND_STRUCTS = [ 'ReconnectParams', 'ReconnectReplayResult', 'ReconnectSnapshotResult', 'SubscribeParams', 'SubscribeView', 'SubscriptionDeliveryOptions', 'SubscribeResult', 'SessionForkSource', 'CreateSessionParams', 'DisposeSessionParams', - 'ChatForkSource', 'CreateChatParams', 'DisposeChatParams', + 'CreateChatParams', 'DisposeChatParams', 'ListSessionsParams', 'ListSessionsResult', 'ResourceReadParams', 'ResourceReadResult', 'ResourceWriteParams', 'ResourceWriteResult', @@ -1469,6 +1547,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 @@ -1556,6 +1643,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; @@ -1569,6 +1672,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)); @@ -1938,6 +2046,7 @@ function checkExhaustiveness(project: Project): void { 'ChatInputAnswerValue', // CHAT_INPUT_ANSWER_VALUE_UNION discriminated union 'ChatInputAnswer', // CHAT_INPUT_ANSWER_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 @@ -1963,6 +2072,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-rust.ts b/scripts/generate-rust.ts index 94d41c48..98ea868e 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -631,8 +631,6 @@ function generateDiscriminatedUnion(cfg: UnionConfig): string { return lines.join('\n'); } -// ─── Interface → Rust Struct (auto) ────────────────────────────────────────── - function generateStructFromInterface( project: Project, tsInterfaceName: string, @@ -701,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 }, @@ -738,6 +737,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 +943,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, }; @@ -1054,10 +1055,22 @@ pub enum ChatOrigin { Fork { /// URI of the chat this one was forked from. chat: Uri, - /// Turn the fork was taken from. + /// 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")] + SideChat { + /// URI of the chat that supplied the side-chat context. + chat: Uri, + /// 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")] Tool { @@ -1304,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 @@ -1403,7 +1416,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 +1427,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: 'ForkChatSource', omitDiscriminants: true }, { name: 'SideChatSource', omitDiscriminants: true }, { name: 'CreateChatParams' }, { name: 'DisposeChatParams' }, { name: 'ListSessionsParams' }, { name: 'ListSessionsResult' }, { name: 'ResourceReadParams' }, { name: 'ResourceReadResult' }, @@ -1450,12 +1463,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, ContentRef, Message, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, SideChatSelection, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn};'); lines.push(''); lines.push('// ─── Enums ────────────────────────────────────────────────────────────\n'); @@ -1487,6 +1510,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(''); @@ -1854,6 +1881,7 @@ function checkExhaustiveness(project: Project): void { 'ChatToolCallConfirmedAction', // emitted as merged variant 'ChatAction', // source-only union covered by StateAction 'ChatOrigin', // hand-generated union for inline variants + 'ChatSource', 'PingParams', 'TerminalClaim', 'TerminalContentPart', diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 649be849..acc4aa62 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -515,6 +515,69 @@ function generateStructFromInterface( return generateSwiftStruct(name, props); } +function generateFixedChatSourceBranchSwift( + name: 'ForkChatSource' | 'SideChatSource', + kindCase: 'fork' | 'sideChat', + doc: string, + turnIdDoc: string, +): string { + const hasSelection = name === 'SideChatSource'; + 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', + ...(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' + (hasSelection ? ',' : ''), + ...(hasSelection ? [' selection: SideChatSelection? = nil'] : []), + ' ) {', + ' self.chat = chat', + ' self.turnId = turnId', + ...(hasSelection ? [' self.selection = selection'] : []), + ' }', + '', + ' 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)', + ...(hasSelection ? [' self.selection = try container.decodeIfPresent(SideChatSelection.self, forKey: .selection)'] : []), + ' }', + '', + ' 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)', + ...(hasSelection ? [' try container.encodeIfPresent(selection, forKey: .selection)'] : []), + ' }', + '}', + ]; + 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 @@ -556,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', @@ -572,7 +635,7 @@ const STATE_STRUCTS = [ 'ChatInputRequest', 'TextPosition', 'TextRange', 'TextSelection', 'SimpleMessageAttachment', 'MessageEmbeddedResourceAttachment', 'MessageResourceAttachment', - 'MessageAnnotationsAttachment', + 'MessageAnnotationsAttachment', 'MessageChatAttachment', 'MarkdownResponsePart', 'ContentRef', 'ResourceReponsePart', 'ToolCallResponsePart', 'ReasoningResponsePart', 'SystemNotificationResponsePart', 'InputRequestResponsePart', @@ -724,6 +787,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 +1053,24 @@ public struct ChatOriginTool: Codable, Sendable { } } +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, selection: SideChatSelection? = nil) { + self.kind = kind + self.chat = chat + self.turnId = turnId + self.selection = selection + } +} + 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 +1081,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 +1092,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 +1427,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', + 'CreateChatParams', 'DisposeChatParams', 'ListSessionsParams', 'ListSessionsResult', 'ResourceReadParams', 'ResourceReadResult', 'ResourceWriteParams', 'ResourceWriteResult', @@ -1387,6 +1468,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]; @@ -1400,6 +1490,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) { @@ -1414,6 +1518,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(''); @@ -1975,6 +2083,7 @@ function checkExhaustiveness(project: Project): void { 'ChatInputAnswerValue', // SESSION_INPUT_ANSWER_VALUE_UNION discriminated union 'ChatInputAnswer', // CHAT_INPUT_ANSWER_UNION discriminated union 'ChatOrigin', // hand-generated union for inline variants + 'ChatSource', // CHAT_SOURCE_UNION discriminated union 'ChatToolCallApprovedAction', 'ChatToolCallDeniedAction', 'ChatToolCallConfirmedAction', @@ -2000,6 +2109,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 ]); diff --git a/types/channels-chat/commands.ts b/types/channels-chat/commands.ts index 0e78d31c..42da6f56 100644 --- a/types/channels-chat/commands.ts +++ b/types/channels-chat/commands.ts @@ -6,20 +6,73 @@ 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 ────────────────────────────────────────────────────────────── /** - * 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', +} + +/** + * 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; - /** Turn ID in the source chat; content up to and including this turn's response is copied */ + /** + * Completed turn identifier in the source chat. + * + * Content through this turn is copied into the new chat's visible `turns`. + */ turnId: string; } +/** + * 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; + /** + * 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. + */ + 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; +} + +/** + * Identifies a source chat for a new chat. + */ +export type ChatSource = + | ForkChatSource + | SideChatSource; + /** * Creates a new chat within a session. * @@ -36,14 +89,29 @@ 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 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`. 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`. 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; /** * 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 (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}. @@ -57,8 +125,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 chat whose + * `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 bfccceed..e08074aa 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -175,14 +175,56 @@ 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', } +/** + * 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). * + * 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 + * 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`. 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 * is the canonical record of the spawn relationship. The same edge is surfaced @@ -194,6 +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; selection?: SideChatSelection } | { kind: ChatOriginKind.Tool; chat: URI; toolCallId: string }; /** @@ -513,6 +556,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 +820,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 +853,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 92b4fa08..5b05e45c 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,24 @@ 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. 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 fd5d5421..fd967dda 100644 --- a/types/index.ts +++ b/types/index.ts @@ -14,6 +14,8 @@ export type { RootState, RootConfigState, AgentInfo, + AgentCapabilities, + MultipleChatsCapability, ProtectedResourceMetadata, ProjectInfo, SessionModelInfo, @@ -24,6 +26,7 @@ export type { ChatState, ChatSummary, ChatOrigin, + SideChatSelection, ChangesSummary, SessionConfigState, Turn, @@ -39,6 +42,7 @@ export type { MessageEmbeddedResourceAttachment, MessageResourceAttachment, MessageAnnotationsAttachment, + MessageChatAttachment, MarkdownResponsePart, ContentRef, ToolCallResponsePart, @@ -285,7 +289,9 @@ export type { SessionForkSource, DisposeSessionParams, CreateChatParams, - ChatForkSource, + ChatSource, + ForkChatSource, + SideChatSource, DisposeChatParams, CreateTerminalParams, DisposeTerminalParams, @@ -338,7 +344,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/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 00000000..8cde32d7 --- /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/030-agent-side-chat-capability.json b/types/test-cases/round-trips/030-agent-side-chat-capability.json new file mode 100644 index 00000000..1eef9b1b --- /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 00000000..8d177bc3 --- /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 stable side-chat turn identities for historical source turns.", + "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 00000000..62e1af9d --- /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 00000000..4ebe8434 --- /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 stable historical turnId.", + "type": "ChatSource", + "input": { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turnId": "turn-12" + }, + "acceptableOutputs": [ + { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "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 00000000..2c00900d --- /dev/null +++ b/types/test-cases/round-trips/034-side-chat-origin-active.json @@ -0,0 +1,36 @@ +{ + "name": "side-chat-origin-active", + "group": "A", + "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", + "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", + "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", + "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 00000000..11c5062d --- /dev/null +++ b/types/test-cases/round-trips/035-chat-source-side-chat-active.json @@ -0,0 +1,18 @@ +{ + "name": "chat-source-side-chat-active", + "group": "A", + "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", + "turnId": "turn-active" + }, + "acceptableOutputs": [ + { + "kind": "sideChat", + "chat": "ahp-chat:/main", + "turnId": "turn-active" + } + ] +} diff --git a/types/test-cases/round-trips/036-chat-source-fork.json b/types/test-cases/round-trips/036-chat-source-fork.json new file mode 100644 index 00000000..d7dade5a --- /dev/null +++ b/types/test-cases/round-trips/036-chat-source-fork.json @@ -0,0 +1,18 @@ +{ + "name": "chat-source-fork", + "group": "A", + "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.json b/types/test-cases/round-trips/037-chat-origin-fork.json new file mode 100644 index 00000000..30dd95f5 --- /dev/null +++ b/types/test-cases/round-trips/037-chat-origin-fork.json @@ -0,0 +1,36 @@ +{ + "name": "chat-origin-fork", + "group": "A", + "description": "A session/chatAdded action preserves discriminated 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" + } + } + } + ] +} 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 00000000..5f93be8f --- /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 00000000..8df67ea3 --- /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" + } + } + } + } + ] +}