diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs index 9b164021..7d4a043c 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs @@ -26,6 +26,13 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(AnnotationsState))] [JsonSerializable(typeof(AnnotationsSummary))] [JsonSerializable(typeof(AnnotationsUpdatedAction))] +[JsonSerializable(typeof(AttributionPageSourceLocation))] +[JsonSerializable(typeof(AttributionResponsePart))] +[JsonSerializable(typeof(AttributionSource))] +[JsonSerializable(typeof(AttributionSourceLocation))] +[JsonSerializable(typeof(AttributionSourceLocationKind))] +[JsonSerializable(typeof(AttributionSpan))] +[JsonSerializable(typeof(AttributionTextSourceLocation))] [JsonSerializable(typeof(AuthenticateParams))] [JsonSerializable(typeof(AuthenticateResult))] [JsonSerializable(typeof(AuthRequiredErrorData))] diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index 5ead9d4c..e0a8a498 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -272,6 +272,8 @@ public enum ResponsePartKind InputRequest, [WireValue("error")] Error, + [WireValue("attribution")] + Attribution, } /// Status of a tool call in the lifecycle state machine. @@ -297,6 +299,16 @@ public enum ToolCallStatus Cancelled, } +/// The kind of location within an attribution source. +[JsonConverter(typeof(WireEnumConverter))] +public enum AttributionSourceLocationKind +{ + [WireValue("text")] + Text, + [WireValue("page")] + Page, +} + /// How a tool call was confirmed for execution. /// /// - `NotNeeded` — No confirmation required (auto-approved) @@ -2631,6 +2643,111 @@ public sealed record ErrorResponsePart public bool? Resumable { get; init; } } +/// Sources that support an earlier markdown or reasoning part in the same turn. +/// +/// The host appends this part with `chat/responsePart` after the target's last +/// text delta and before the turn ends. At most one attribution part may target +/// a given part. Neither the target text nor its attribution changes afterward, +/// including when the turn resumes; further output uses new part identifiers. +/// +/// Clients MAY show inline citations or a source list. Clients that do not +/// support attribution can ignore this part and still render the original text. +public sealed record AttributionResponsePart +{ + /// Discriminant + public ResponsePartKind Kind { get; init; } + + /// Non-empty identifier, unique among response parts in this turn. + public required string Id { get; init; } + + /// Identifier of the earlier MarkdownResponsePart or ReasoningResponsePart. + public required string TargetPartId { get; init; } + + /// Supporting sources. MUST contain at least one entry, with distinct IDs. + public required List Sources { get; init; } + + /// Ranges of target text linked to sources. An empty list supplies sources for + /// the target as a whole without claiming a more precise text-to-source mapping. + public required List Spans { get; init; } + + /// Optional implementation-specific details; not needed to display attribution. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// A source, or a passage within a source, supporting an attributed response. +/// +/// A source need not be a public URL or a local file. Hosts SHOULD provide a +/// title when no URI is available and prefer versioned URIs when possible. +/// Two entries may share a URI when they describe different passages. +public sealed record AttributionSource +{ + /// Non-empty identifier, unique within the containing attribution part. + public required string Id { get; init; } + + /// Human-readable source title, rendered as plain text. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Source URI. Its presence does not authorize opening or fetching the resource. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Uri { get; init; } + + /// MIME type of the source, when known. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; init; } + + /// Optional short quotation from the source, not a generated answer summary. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Excerpt { get; init; } + + /// Location within the source, not within the generated response. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AttributionSourceLocation? Location { get; init; } + + /// Optional implementation-specific details; not needed to display the source. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// A non-empty range of generated text linked to supporting sources. +/// +/// Ranges address the target part's raw `content`, before Markdown rendering: +/// zero-based lines and UTF-16 code-unit character offsets, start inclusive and +/// end exclusive. CRLF, LF, and lone CR each count as one line break. Positions +/// MUST lie within the text and MUST NOT split a surrogate pair. +public sealed record AttributionSpan +{ + /// Range within the target part, not within any source or the combined turn. + public required TextRange Range { get; init; } + + /// Non-empty, distinct IDs from the containing AttributionResponsePart.sources. + public required List SourceIds { get; init; } +} + +/// A range within a textual source, using the same position rules as AttributionSpan. +public sealed record AttributionTextSourceLocation +{ + public AttributionSourceLocationKind Kind { get; init; } + + /// Non-empty, start-inclusive, end-exclusive range within the source text. + public required TextRange Range { get; init; } +} + +/// An inclusive range of pages within a document. +public sealed record AttributionPageSourceLocation +{ + public AttributionSourceLocationKind Kind { get; init; } + + /// First page, numbered from one. + public long StartPage { get; init; } + + /// Last page, inclusive. MUST be greater than or equal to startPage. + public long EndPage { get; init; } +} + /// Tool execution result details, available after execution completes. public sealed record ToolCallResult { @@ -5528,6 +5645,34 @@ public ResponsePartConverter() ["systemNotification"] = typeof(SystemNotificationResponsePart), ["inputRequest"] = typeof(InputRequestResponsePart), ["error"] = typeof(ErrorResponsePart), + ["attribution"] = typeof(AttributionResponsePart), + }, + allowUnknown: true) + { + } +} + +/// A location within a source supporting an attributed response. +[JsonConverter(typeof(AttributionSourceLocationConverter))] +public sealed class AttributionSourceLocation : AhpUnion +{ + /// Creates an empty AttributionSourceLocation (no active variant). + public AttributionSourceLocation() { } + + /// Creates a AttributionSourceLocation wrapping the given variant value. + public AttributionSourceLocation(object? value) : base(value) { } +} + +/// System.Text.Json converter for the AttributionSourceLocation discriminated union. +internal sealed class AttributionSourceLocationConverter : UnionConverter +{ + public AttributionSourceLocationConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["text"] = typeof(AttributionTextSourceLocation), + ["page"] = typeof(AttributionPageSourceLocation), }, allowUnknown: true) { diff --git a/clients/dotnet/src/AgentHostProtocol/Reducers.cs b/clients/dotnet/src/AgentHostProtocol/Reducers.cs index f132f9d8..b6aa9b57 100644 --- a/clients/dotnet/src/AgentHostProtocol/Reducers.cs +++ b/clients/dotnet/src/AgentHostProtocol/Reducers.cs @@ -590,6 +590,7 @@ private static ReduceOutcome UpdateResponsePart( ToolCallResponsePart v => ToolCallId(v.ToolCall), MarkdownResponsePart v => v.Id, ReasoningResponsePart v => v.Id, + AttributionResponsePart v => v.Id, _ => string.Empty, }; diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 6da0af86..7f47e029 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -457,6 +457,8 @@ func updateResponsePart(state *ahptypes.ChatState, turnID, partID string, update id = v.Id case *ahptypes.ReasoningResponsePart: id = v.Id + case *ahptypes.AttributionResponsePart: + id = v.Id } if id != "" && id == partID { updater(part) diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 269b50f5..691135d1 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -219,6 +219,7 @@ const ( ResponsePartKindSystemNotification ResponsePartKind = "systemNotification" ResponsePartKindInputRequest ResponsePartKind = "inputRequest" ResponsePartKindError ResponsePartKind = "error" + ResponsePartKindAttribution ResponsePartKind = "attribution" ) // Status of a tool call in the lifecycle state machine. @@ -237,6 +238,14 @@ const ( ToolCallStatusCancelled ToolCallStatus = "cancelled" ) +// The kind of location within an attribution source. +type AttributionSourceLocationKind string + +const ( + AttributionSourceLocationKindText AttributionSourceLocationKind = "text" + AttributionSourceLocationKindPage AttributionSourceLocationKind = "page" +) + // How a tool call was confirmed for execution. // // - `NotNeeded` — No confirmation required (auto-approved) @@ -1994,6 +2003,82 @@ type ErrorResponsePart struct { Resumable *bool `json:"resumable,omitempty"` } +// Sources that support an earlier markdown or reasoning part in the same turn. +// +// The host appends this part with `chat/responsePart` after the target's last +// text delta and before the turn ends. At most one attribution part may target +// a given part. Neither the target text nor its attribution changes afterward, +// including when the turn resumes; further output uses new part identifiers. +// +// Clients MAY show inline citations or a source list. Clients that do not +// support attribution can ignore this part and still render the original text. +type AttributionResponsePart struct { + // Discriminant + Kind ResponsePartKind `json:"kind"` + // Non-empty identifier, unique among response parts in this turn. + Id string `json:"id"` + // Identifier of the earlier MarkdownResponsePart or ReasoningResponsePart. + TargetPartId string `json:"targetPartId"` + // Supporting sources. MUST contain at least one entry, with distinct IDs. + Sources []AttributionSource `json:"sources"` + // Ranges of target text linked to sources. An empty list supplies sources for + // the target as a whole without claiming a more precise text-to-source mapping. + Spans []AttributionSpan `json:"spans"` + // Optional implementation-specific details; not needed to display attribution. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` +} + +// A source, or a passage within a source, supporting an attributed response. +// +// A source need not be a public URL or a local file. Hosts SHOULD provide a +// title when no URI is available and prefer versioned URIs when possible. +// Two entries may share a URI when they describe different passages. +type AttributionSource struct { + // Non-empty identifier, unique within the containing attribution part. + Id string `json:"id"` + // Human-readable source title, rendered as plain text. + Title *string `json:"title,omitempty"` + // Source URI. Its presence does not authorize opening or fetching the resource. + Uri *URI `json:"uri,omitempty"` + // MIME type of the source, when known. + ContentType *string `json:"contentType,omitempty"` + // Optional short quotation from the source, not a generated answer summary. + Excerpt *string `json:"excerpt,omitempty"` + // Location within the source, not within the generated response. + Location *AttributionSourceLocation `json:"location,omitempty"` + // Optional implementation-specific details; not needed to display the source. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` +} + +// A non-empty range of generated text linked to supporting sources. +// +// Ranges address the target part's raw `content`, before Markdown rendering: +// zero-based lines and UTF-16 code-unit character offsets, start inclusive and +// end exclusive. CRLF, LF, and lone CR each count as one line break. Positions +// MUST lie within the text and MUST NOT split a surrogate pair. +type AttributionSpan struct { + // Range within the target part, not within any source or the combined turn. + Range TextRange `json:"range"` + // Non-empty, distinct IDs from the containing AttributionResponsePart.sources. + SourceIds []string `json:"sourceIds"` +} + +// A range within a textual source, using the same position rules as AttributionSpan. +type AttributionTextSourceLocation struct { + Kind AttributionSourceLocationKind `json:"kind"` + // Non-empty, start-inclusive, end-exclusive range within the source text. + Range TextRange `json:"range"` +} + +// An inclusive range of pages within a document. +type AttributionPageSourceLocation struct { + Kind AttributionSourceLocationKind `json:"kind"` + // First page, numbered from one. + StartPage int64 `json:"startPage"` + // Last page, inclusive. MUST be greater than or equal to startPage. + EndPage int64 `json:"endPage"` +} + // Tool execution result details, available after execution completes. type ToolCallResult struct { // Whether the tool succeeded @@ -4216,6 +4301,7 @@ func (*ReasoningResponsePart) isResponsePart() {} func (*SystemNotificationResponsePart) isResponsePart() {} func (*InputRequestResponsePart) isResponsePart() {} func (*ErrorResponsePart) isResponsePart() {} +func (*AttributionResponsePart) isResponsePart() {} // ResponsePartUnknown carries an unrecognized ResponsePart 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 ResponsePartUnknown struct { @@ -4273,6 +4359,12 @@ func (u *ResponsePart) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "attribution": + var value AttributionResponsePart + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value default: raw := make(json.RawMessage, len(data)) copy(raw, data) @@ -4295,6 +4387,66 @@ func (u ResponsePart) MarshalJSON() ([]byte, error) { return json.Marshal(u.Value) } +// AttributionSourceLocation identifies a passage within a supporting source. +type AttributionSourceLocation struct { + Value isAttributionSourceLocation +} + +// isAttributionSourceLocation is the marker interface implemented by every +// concrete variant of AttributionSourceLocation. +type isAttributionSourceLocation interface{ isAttributionSourceLocation() } + +func (*AttributionTextSourceLocation) isAttributionSourceLocation() {} +func (*AttributionPageSourceLocation) isAttributionSourceLocation() {} + +// AttributionSourceLocationUnknown carries an unrecognized AttributionSourceLocation 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 AttributionSourceLocationUnknown struct { + Raw json.RawMessage +} + +func (*AttributionSourceLocationUnknown) isAttributionSourceLocation() {} + +// UnmarshalJSON decodes the variant indicated by the "kind" discriminator. +func (u *AttributionSourceLocation) UnmarshalJSON(data []byte) error { + disc, _, err := readDiscriminator(data, "kind") + if err != nil { + return err + } + switch disc { + case "text": + var value AttributionTextSourceLocation + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "page": + var value AttributionPageSourceLocation + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + default: + raw := make(json.RawMessage, len(data)) + copy(raw, data) + u.Value = &AttributionSourceLocationUnknown{Raw: raw} + } + return nil +} + +// MarshalJSON encodes the active variant back to JSON. +func (u AttributionSourceLocation) MarshalJSON() ([]byte, error) { + if unk, ok := u.Value.(*AttributionSourceLocationUnknown); ok { + if len(unk.Raw) == 0 { + return []byte("null"), nil + } + return unk.Raw, nil + } + if u.Value == nil { + return []byte("null"), nil + } + return json.Marshal(u.Value) +} + // ToolCallState is the full tool call lifecycle state. type ToolCallState struct { Value isToolCallState diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt index 87c7493f..89cefaf5 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -372,6 +372,7 @@ private fun updateResponsePart( is ResponsePartToolCall -> toolCallIdOf(part.value.toolCall) is ResponsePartMarkdown -> part.value.id is ResponsePartReasoning -> part.value.id + is ResponsePartAttribution -> part.value.id else -> null } if (id == partId) { 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 9fee270b..52d0cfb6 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 @@ -472,6 +472,7 @@ value class ResponsePartKind(val rawValue: String) { val SYSTEM_NOTIFICATION: ResponsePartKind = ResponsePartKind("systemNotification") val INPUT_REQUEST: ResponsePartKind = ResponsePartKind("inputRequest") val ERROR: ResponsePartKind = ResponsePartKind("error") + val ATTRIBUTION: ResponsePartKind = ResponsePartKind("attribution") } } @@ -517,6 +518,28 @@ internal object ToolCallStatusSerializer : KSerializer { ToolCallStatus(decoder.decodeString()) } +/** + * The kind of location within an attribution source. + */ +@Serializable(with = AttributionSourceLocationKindSerializer::class) +@JvmInline +value class AttributionSourceLocationKind(val rawValue: String) { + companion object { + val TEXT: AttributionSourceLocationKind = AttributionSourceLocationKind("text") + val PAGE: AttributionSourceLocationKind = AttributionSourceLocationKind("page") + } +} + +internal object AttributionSourceLocationKindSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("AttributionSourceLocationKind", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: AttributionSourceLocationKind) { + encoder.encodeString(value.rawValue) + } + override fun deserialize(decoder: Decoder): AttributionSourceLocationKind = + AttributionSourceLocationKind(decoder.decodeString()) +} + /** * How a tool call was confirmed for execution. * @@ -2941,6 +2964,103 @@ data class ErrorResponsePart( val resumable: Boolean? = null ) +@Serializable +data class AttributionResponsePart( + /** + * Discriminant + */ + val kind: ResponsePartKind, + /** + * Non-empty identifier, unique among response parts in this turn. + */ + val id: String, + /** + * Identifier of the earlier MarkdownResponsePart or ReasoningResponsePart. + */ + val targetPartId: String, + /** + * Supporting sources. MUST contain at least one entry, with distinct IDs. + */ + val sources: List, + /** + * Ranges of target text linked to sources. An empty list supplies sources for + * the target as a whole without claiming a more precise text-to-source mapping. + */ + val spans: List, + /** + * Optional implementation-specific details; not needed to display attribution. + */ + @SerialName("_meta") + val meta: Map? = null +) + +@Serializable +data class AttributionSource( + /** + * Non-empty identifier, unique within the containing attribution part. + */ + val id: String, + /** + * Human-readable source title, rendered as plain text. + */ + val title: String? = null, + /** + * Source URI. Its presence does not authorize opening or fetching the resource. + */ + val uri: String? = null, + /** + * MIME type of the source, when known. + */ + val contentType: String? = null, + /** + * Optional short quotation from the source, not a generated answer summary. + */ + val excerpt: String? = null, + /** + * Location within the source, not within the generated response. + */ + val location: AttributionSourceLocation? = null, + /** + * Optional implementation-specific details; not needed to display the source. + */ + @SerialName("_meta") + val meta: Map? = null +) + +@Serializable +data class AttributionSpan( + /** + * Range within the target part, not within any source or the combined turn. + */ + val range: TextRange, + /** + * Non-empty, distinct IDs from the containing AttributionResponsePart.sources. + */ + val sourceIds: List +) + +@Serializable +data class AttributionTextSourceLocation( + val kind: AttributionSourceLocationKind, + /** + * Non-empty, start-inclusive, end-exclusive range within the source text. + */ + val range: TextRange +) + +@Serializable +data class AttributionPageSourceLocation( + val kind: AttributionSourceLocationKind, + /** + * First page, numbered from one. + */ + val startPage: Long, + /** + * Last page, inclusive. MUST be greater than or equal to startPage. + */ + val endPage: Long +) + @Serializable data class ToolCallResult( /** @@ -5799,6 +5919,8 @@ value class ResponsePartSystemNotification(val value: SystemNotificationResponse value class ResponsePartInputRequest(val value: InputRequestResponsePart) : ResponsePart @JvmInline value class ResponsePartError(val value: ErrorResponsePart) : ResponsePart +@JvmInline +value class ResponsePartAttribution(val value: AttributionResponsePart) : ResponsePart /** * Forward-compat catch-all for unknown ResponsePart discriminators. * @@ -5830,6 +5952,7 @@ internal object ResponsePartSerializer : KSerializer { "systemNotification" -> ResponsePartSystemNotification(input.json.decodeFromJsonElement(SystemNotificationResponsePart.serializer(), element)) "inputRequest" -> ResponsePartInputRequest(input.json.decodeFromJsonElement(InputRequestResponsePart.serializer(), element)) "error" -> ResponsePartError(input.json.decodeFromJsonElement(ErrorResponsePart.serializer(), element)) + "attribution" -> ResponsePartAttribution(input.json.decodeFromJsonElement(AttributionResponsePart.serializer(), element)) else -> ResponsePartUnknown(obj) } } @@ -5845,12 +5968,62 @@ internal object ResponsePartSerializer : KSerializer { is ResponsePartSystemNotification -> output.json.encodeToJsonElement(SystemNotificationResponsePart.serializer(), value.value) is ResponsePartInputRequest -> output.json.encodeToJsonElement(InputRequestResponsePart.serializer(), value.value) is ResponsePartError -> output.json.encodeToJsonElement(ErrorResponsePart.serializer(), value.value) + is ResponsePartAttribution -> output.json.encodeToJsonElement(AttributionResponsePart.serializer(), value.value) is ResponsePartUnknown -> value.raw } output.encodeJsonElement(element) } } +@Serializable(with = AttributionSourceLocationSerializer::class) +sealed interface AttributionSourceLocation + +@JvmInline +value class AttributionSourceLocationText(val value: AttributionTextSourceLocation) : AttributionSourceLocation +@JvmInline +value class AttributionSourceLocationPage(val value: AttributionPageSourceLocation) : AttributionSourceLocation +/** + * Forward-compat catch-all for unknown AttributionSourceLocation discriminators. + * + * Older clients may receive newer wire variants they don't recognise; capturing + * the raw `JsonObject` lets such payloads round-trip through the client unchanged. + * Reducers handle this variant conservatively on a per-union basis (typically + * as a no-op, but see `Reducers.kt` for the exact treatment). + */ +@JvmInline +value class AttributionSourceLocationUnknown(val raw: JsonObject) : AttributionSourceLocation + +internal object AttributionSourceLocationSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("AttributionSourceLocation") + + override fun deserialize(decoder: Decoder): AttributionSourceLocation { + val input = decoder as? JsonDecoder + ?: error("AttributionSourceLocation can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for AttributionSourceLocation") + val discriminant = (obj["kind"] as? JsonPrimitive)?.content + ?: return AttributionSourceLocationUnknown(obj) + return when (discriminant) { + "text" -> AttributionSourceLocationText(input.json.decodeFromJsonElement(AttributionTextSourceLocation.serializer(), element)) + "page" -> AttributionSourceLocationPage(input.json.decodeFromJsonElement(AttributionPageSourceLocation.serializer(), element)) + else -> AttributionSourceLocationUnknown(obj) + } + } + + override fun serialize(encoder: Encoder, value: AttributionSourceLocation) { + val output = encoder as? JsonEncoder + ?: error("AttributionSourceLocation can only be serialized to JSON") + val element: JsonElement = when (value) { + is AttributionSourceLocationText -> output.json.encodeToJsonElement(AttributionTextSourceLocation.serializer(), value.value) + is AttributionSourceLocationPage -> output.json.encodeToJsonElement(AttributionPageSourceLocation.serializer(), value.value) + is AttributionSourceLocationUnknown -> value.raw + } + output.encodeJsonElement(element) + } +} + @Serializable(with = ToolCallStateSerializer::class) sealed interface ToolCallState diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 6d7ff245..381f864d 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -522,6 +522,7 @@ pub enum ResponsePartKind { SystemNotification, InputRequest, Error, + Attribution, /// Unknown raw value from a newer protocol version, preserved verbatim. Unknown(String), } @@ -539,6 +540,7 @@ impl serde::Serialize for ResponsePartKind { Self::SystemNotification => serializer.serialize_str("systemNotification"), Self::InputRequest => serializer.serialize_str("inputRequest"), Self::Error => serializer.serialize_str("error"), + Self::Attribution => serializer.serialize_str("attribution"), Self::Unknown(value) => serializer.serialize_str(value), } } @@ -558,6 +560,7 @@ impl<'de> serde::Deserialize<'de> for ResponsePartKind { "systemNotification" => Self::SystemNotification, "inputRequest" => Self::InputRequest, "error" => Self::Error, + "attribution" => Self::Attribution, _ => Self::Unknown(raw), }) } @@ -619,6 +622,42 @@ impl<'de> serde::Deserialize<'de> for ToolCallStatus { } } +/// The kind of location within an attribution source. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum AttributionSourceLocationKind { + Text, + Page, + /// Unknown raw value from a newer protocol version, preserved verbatim. + Unknown(String), +} + +impl serde::Serialize for AttributionSourceLocationKind { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Text => serializer.serialize_str("text"), + Self::Page => serializer.serialize_str("page"), + Self::Unknown(value) => serializer.serialize_str(value), + } + } +} + +impl<'de> serde::Deserialize<'de> for AttributionSourceLocationKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = ::deserialize(deserializer)?; + Ok(match raw.as_str() { + "text" => Self::Text, + "page" => Self::Page, + _ => Self::Unknown(raw), + }) + } +} + /// How a tool call was confirmed for execution. /// /// - `NotNeeded` — No confirmation required (auto-approved) @@ -3199,6 +3238,95 @@ pub struct ErrorResponsePart { pub resumable: Option, } +/// Sources that support an earlier markdown or reasoning part in the same turn. +/// +/// The host appends this part with `chat/responsePart` after the target's last +/// text delta and before the turn ends. At most one attribution part may target +/// a given part. Neither the target text nor its attribution changes afterward, +/// including when the turn resumes; further output uses new part identifiers. +/// +/// Clients MAY show inline citations or a source list. Clients that do not +/// support attribution can ignore this part and still render the original text. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttributionResponsePart { + /// Non-empty identifier, unique among response parts in this turn. + pub id: String, + /// Identifier of the earlier MarkdownResponsePart or ReasoningResponsePart. + pub target_part_id: String, + /// Supporting sources. MUST contain at least one entry, with distinct IDs. + pub sources: Vec, + /// Ranges of target text linked to sources. An empty list supplies sources for + /// the target as a whole without claiming a more precise text-to-source mapping. + pub spans: Vec, + /// Optional implementation-specific details; not needed to display attribution. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// A source, or a passage within a source, supporting an attributed response. +/// +/// A source need not be a public URL or a local file. Hosts SHOULD provide a +/// title when no URI is available and prefer versioned URIs when possible. +/// Two entries may share a URI when they describe different passages. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttributionSource { + /// Non-empty identifier, unique within the containing attribution part. + pub id: String, + /// Human-readable source title, rendered as plain text. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Source URI. Its presence does not authorize opening or fetching the resource. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uri: Option, + /// MIME type of the source, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_type: Option, + /// Optional short quotation from the source, not a generated answer summary. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub excerpt: Option, + /// Location within the source, not within the generated response. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub location: Option, + /// Optional implementation-specific details; not needed to display the source. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// A non-empty range of generated text linked to supporting sources. +/// +/// Ranges address the target part's raw `content`, before Markdown rendering: +/// zero-based lines and UTF-16 code-unit character offsets, start inclusive and +/// end exclusive. CRLF, LF, and lone CR each count as one line break. Positions +/// MUST lie within the text and MUST NOT split a surrogate pair. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttributionSpan { + /// Range within the target part, not within any source or the combined turn. + pub range: TextRange, + /// Non-empty, distinct IDs from the containing AttributionResponsePart.sources. + pub source_ids: Vec, +} + +/// A range within a textual source, using the same position rules as AttributionSpan. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttributionTextSourceLocation { + /// Non-empty, start-inclusive, end-exclusive range within the source text. + pub range: TextRange, +} + +/// An inclusive range of pages within a document. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttributionPageSourceLocation { + /// First page, numbered from one. + pub start_page: i64, + /// Last page, inclusive. MUST be greater than or equal to startPage. + pub end_page: i64, +} + /// Tool execution result details, available after execution completes. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -5734,6 +5862,22 @@ pub enum ResponsePart { InputRequest(InputRequestResponsePart), #[serde(rename = "error")] Error(ErrorResponsePart), + #[serde(rename = "attribution")] + Attribution(AttributionResponsePart), + /// Unknown or future variant — preserved as raw JSON for round-trip fidelity. + /// Reducers treat this as a no-op. + #[serde(untagged)] + Unknown(serde_json::Value), +} + +/// A location within a source supporting an attributed response. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum AttributionSourceLocation { + #[serde(rename = "text")] + Text(AttributionTextSourceLocation), + #[serde(rename = "page")] + Page(AttributionPageSourceLocation), /// 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/attribution.rs b/clients/rust/crates/ahp-types/tests/attribution.rs new file mode 100644 index 00000000..b6a7f94f --- /dev/null +++ b/clients/rust/crates/ahp-types/tests/attribution.rs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +#![allow(clippy::panic)] + +use ahp_types::{ + actions::{ActionEnvelope, StateAction}, + state::{AttributionSourceLocation, ResponsePart}, +}; + +#[test] +fn attribution_and_source_locations_decode_as_known_variants() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../../types/test-cases/round-trips/045-response-attribution-action.json" + )) + .expect("read attribution fixture"); + let envelope: ActionEnvelope = + serde_json::from_value(fixture["input"].clone()).expect("decode attribution action"); + let StateAction::ChatResponsePart(action) = &envelope.action else { + panic!("expected response-part action, got {:?}", envelope.action); + }; + let ResponsePart::Attribution(part) = &action.part else { + panic!("expected typed attribution, got {:?}", action.part); + }; + assert_eq!(part.target_part_id, "answer-1"); + assert_eq!(part.spans[1].source_ids, ["file", "document"]); + assert!(matches!( + &part.sources[0].location, + Some(AttributionSourceLocation::Text(location)) if location.range.start.line == 2 + )); + assert!(matches!( + &part.sources[1].location, + Some(AttributionSourceLocation::Page(location)) + if location.start_page == 4 && location.end_page == 5 + )); + assert_eq!( + serde_json::to_value(envelope).expect("encode attribution action"), + fixture["acceptableOutputs"][0] + ); +} diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 95b02ff4..26e0378e 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -639,6 +639,7 @@ where ResponsePart::ToolCall(tc) => Some(tool_call_id(&tc.tool_call).to_owned()), ResponsePart::Markdown(m) => Some(m.id.clone()), ResponsePart::Reasoning(r) => Some(r.id.clone()), + ResponsePart::Attribution(a) => Some(a.id.clone()), ResponsePart::Error(_) | ResponsePart::ContentRef(_) | ResponsePart::SystemNotification(_) diff --git a/clients/swift/AHPApp/AHPApp/Views/ResponsePartView.swift b/clients/swift/AHPApp/AHPApp/Views/ResponsePartView.swift index b2727844..15f57396 100644 --- a/clients/swift/AHPApp/AHPApp/Views/ResponsePartView.swift +++ b/clients/swift/AHPApp/AHPApp/Views/ResponsePartView.swift @@ -28,7 +28,7 @@ struct ResponsePartView: View { SystemNotificationPartView(part: note) case .error(let error): ErrorResponsePartView(part: error) - case .inputRequest, .unknown: + case .attribution, .inputRequest, .unknown: EmptyView() } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 313783d6..e37f9ca5 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -394,6 +394,7 @@ public enum ResponsePartKind: Codable, Sendable, Equatable { case systemNotification case inputRequest case error + case attribution /// Unknown raw value from a newer protocol version, preserved verbatim. case unknown(String) @@ -408,6 +409,7 @@ public enum ResponsePartKind: Codable, Sendable, Equatable { case "systemNotification": self = .systemNotification case "inputRequest": self = .inputRequest case "error": self = .error + case "attribution": self = .attribution default: self = .unknown(raw) } } @@ -422,6 +424,7 @@ public enum ResponsePartKind: Codable, Sendable, Equatable { case .systemNotification: try container.encode("systemNotification") case .inputRequest: try container.encode("inputRequest") case .error: try container.encode("error") + case .attribution: try container.encode("attribution") case .unknown(let raw): try container.encode(raw) } } @@ -472,6 +475,33 @@ public enum ToolCallStatus: Codable, Sendable, Equatable { } } +/// The kind of location within an attribution source. +public enum AttributionSourceLocationKind: Codable, Sendable, Equatable { + case text + case page + /// Unknown raw value from a newer protocol version, preserved verbatim. + case unknown(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + switch raw { + case "text": self = .text + case "page": self = .page + default: self = .unknown(raw) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .text: try container.encode("text") + case .page: try container.encode("page") + case .unknown(let raw): try container.encode(raw) + } + } +} + /// How a tool call was confirmed for execution. /// /// - `NotNeeded` — No confirmation required (auto-approved) @@ -3275,6 +3305,139 @@ public struct ErrorResponsePart: Codable, Sendable { } } +public struct AttributionResponsePart: Codable, Sendable { + /// Discriminant + public var kind: ResponsePartKind + /// Non-empty identifier, unique among response parts in this turn. + public var id: String + /// Identifier of the earlier MarkdownResponsePart or ReasoningResponsePart. + public var targetPartId: String + /// Supporting sources. MUST contain at least one entry, with distinct IDs. + public var sources: [AttributionSource] + /// Ranges of target text linked to sources. An empty list supplies sources for + /// the target as a whole without claiming a more precise text-to-source mapping. + public var spans: [AttributionSpan] + /// Optional implementation-specific details; not needed to display attribution. + public var meta: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case kind + case id + case targetPartId + case sources + case spans + case meta = "_meta" + } + + public init( + kind: ResponsePartKind, + id: String, + targetPartId: String, + sources: [AttributionSource], + spans: [AttributionSpan], + meta: [String: AnyCodable]? = nil + ) { + self.kind = kind + self.id = id + self.targetPartId = targetPartId + self.sources = sources + self.spans = spans + self.meta = meta + } +} + +public struct AttributionSource: Codable, Sendable { + /// Non-empty identifier, unique within the containing attribution part. + public var id: String + /// Human-readable source title, rendered as plain text. + public var title: String? + /// Source URI. Its presence does not authorize opening or fetching the resource. + public var uri: String? + /// MIME type of the source, when known. + public var contentType: String? + /// Optional short quotation from the source, not a generated answer summary. + public var excerpt: String? + /// Location within the source, not within the generated response. + public var location: AttributionSourceLocation? + /// Optional implementation-specific details; not needed to display the source. + public var meta: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case id + case title + case uri + case contentType + case excerpt + case location + case meta = "_meta" + } + + public init( + id: String, + title: String? = nil, + uri: String? = nil, + contentType: String? = nil, + excerpt: String? = nil, + location: AttributionSourceLocation? = nil, + meta: [String: AnyCodable]? = nil + ) { + self.id = id + self.title = title + self.uri = uri + self.contentType = contentType + self.excerpt = excerpt + self.location = location + self.meta = meta + } +} + +public struct AttributionSpan: Codable, Sendable { + /// Range within the target part, not within any source or the combined turn. + public var range: TextRange + /// Non-empty, distinct IDs from the containing AttributionResponsePart.sources. + public var sourceIds: [String] + + public init( + range: TextRange, + sourceIds: [String] + ) { + self.range = range + self.sourceIds = sourceIds + } +} + +public struct AttributionTextSourceLocation: Codable, Sendable { + public var kind: AttributionSourceLocationKind + /// Non-empty, start-inclusive, end-exclusive range within the source text. + public var range: TextRange + + public init( + kind: AttributionSourceLocationKind, + range: TextRange + ) { + self.kind = kind + self.range = range + } +} + +public struct AttributionPageSourceLocation: Codable, Sendable { + public var kind: AttributionSourceLocationKind + /// First page, numbered from one. + public var startPage: Int + /// Last page, inclusive. MUST be greater than or equal to startPage. + public var endPage: Int + + public init( + kind: AttributionSourceLocationKind, + startPage: Int, + endPage: Int + ) { + self.kind = kind + self.startPage = startPage + self.endPage = endPage + } +} + public struct ToolCallResult: Codable, Sendable { /// Whether the tool succeeded public var success: Bool @@ -6803,6 +6966,7 @@ public enum ResponsePart: Codable, Sendable { case systemNotification(SystemNotificationResponsePart) case inputRequest(InputRequestResponsePart) case error(ErrorResponsePart) + case attribution(AttributionResponsePart) /// Unknown or future discriminant; the raw payload is preserved /// and re-encoded verbatim for forward-compatibility. case unknown(AnyCodable) @@ -6832,6 +6996,8 @@ public enum ResponsePart: Codable, Sendable { self = .inputRequest(try InputRequestResponsePart(from: decoder)) case "error": self = .error(try ErrorResponsePart(from: decoder)) + case "attribution": + self = .attribution(try AttributionResponsePart(from: decoder)) default: self = .unknown(try AnyCodable(from: decoder)) } @@ -6846,6 +7012,43 @@ public enum ResponsePart: Codable, Sendable { case .systemNotification(let value): try value.encode(to: encoder) case .inputRequest(let value): try value.encode(to: encoder) case .error(let value): try value.encode(to: encoder) + case .attribution(let value): try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) + } + } +} + +public enum AttributionSourceLocation: Codable, Sendable { + case text(AttributionTextSourceLocation) + case page(AttributionPageSourceLocation) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) + + private enum DiscriminantKey: String, CodingKey { + case discriminant = "kind" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + guard let discriminant = try container.decodeIfPresent(String.self, forKey: .discriminant) else { + self = .unknown(try AnyCodable(from: decoder)) + return + } + switch discriminant { + case "text": + self = .text(try AttributionTextSourceLocation(from: decoder)) + case "page": + self = .page(try AttributionPageSourceLocation(from: decoder)) + default: + self = .unknown(try AnyCodable(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .text(let value): try value.encode(to: encoder) + case .page(let value): try value.encode(to: encoder) case .unknown(let value): try value.encode(to: encoder) } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift index e76cee02..73c237dd 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift @@ -94,6 +94,7 @@ extension ResponsePart { switch self { case .markdown(let m): return m.id case .reasoning(let r): return r.id + case .attribution(let a): return a.id case .toolCall(let t): return t.toolCall.toolCallId case .contentRef: return nil case .systemNotification: return nil diff --git a/docs/.changes/20260916-response-attribution.json b/docs/.changes/20260916-response-attribution.json new file mode 100644 index 00000000..30c1c382 --- /dev/null +++ b/docs/.changes/20260916-response-attribution.json @@ -0,0 +1,5 @@ +{ + "type": "added", + "message": "Optional response attribution links answer text to supporting sources and preserves it in chat history across clients.", + "issues": [452] +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index eb69724b..42380049 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -31,6 +31,7 @@ export default withMermaid(defineConfig({ items: [ { text: 'State Model', link: '/guide/state-model' }, { text: 'Actions', link: '/guide/actions' }, + { text: 'Source Attribution', link: '/guide/attribution' }, { text: 'Elicitation', link: '/guide/elicitation' }, { text: 'Terminals', link: '/guide/terminals' }, { text: 'Customizations', link: '/guide/customizations' }, diff --git a/docs/guide/attribution.md b/docs/guide/attribution.md new file mode 100644 index 00000000..687c6e4c --- /dev/null +++ b/docs/guide/attribution.md @@ -0,0 +1,156 @@ +# Source Attribution + +An agent may use web pages, files, uploaded documents, or other material to +support an answer. AHP lets a host describe those sources and the text they +support without requiring a particular agent, model, search tool, or UI. + +Attribution is optional. A client can show inline citations, a source list, or +neither. The answer remains ordinary Markdown or reasoning text and must make +sense on its own. + +## How it works + +1. The host creates a markdown or reasoning part with `chat/responsePart`. +2. It streams text using `chat/delta` or `chat/reasoning`. +3. After the target's last text delta, it appends an `attribution` part using + `chat/responsePart`. +4. It ends the turn as usual. + +The attribution part identifies the earlier text part with `targetPartId`. It +contains a source list and ranges linking the generated text to those sources. +An empty `spans` list attributes the target as a whole without claiming a more +precise mapping. + +Here is attribution for a markdown part named `answer-1` whose complete content +is `The library opened in 1984.`. The range selects `1984`: + +```json +{ + "type": "chat/responsePart", + "turnId": "turn-1", + "part": { + "kind": "attribution", + "id": "attribution-1", + "targetPartId": "answer-1", + "sources": [ + { + "id": "history", + "title": "Library history", + "uri": "https://example.org/library/history", + "contentType": "text/html", + "excerpt": "The library opened its doors in 1984." + } + ], + "spans": [ + { + "range": { + "start": { "line": 0, "character": 22 }, + "end": { "line": 0, "character": 26 } + }, + "sourceIds": ["history"] + } + ] + } +} +``` + +This action travels on the owning `ahp-chat:` channel, like other response +parts. No new command or streaming action is required. + +## Identifiers and lifetime + +- The attribution `id` MUST be non-empty and unique among the turn's response + part identifiers. +- `targetPartId` MUST identify an earlier markdown or reasoning part in the + **same turn**, not a message ID or a part from another chat. +- A host MUST send at most one attribution part for each target. It MUST NOT + append more text to that target or replace the attribution afterward. If + more output is needed, including after a turn resumes, use new part IDs. +- `sources` MUST contain at least one source. Source IDs MUST be non-empty and + distinct within this attribution part; they are not global resource IDs. +- Each span MUST name at least one source, without duplicate IDs, and every ID + MUST resolve within this part's `sources`. +- Spans MAY overlap, and a span MAY refer to several sources. A source may + support several spans. Sources without a span are attributed to the target + as a whole, not to an inferred sentence. + +The host MUST normalize and validate attribution before sending it. It should +omit unavailable attribution rather than guess a target, source, or range. + +Attribution remains in the turn's `responseParts` after completion, +cancellation, or error. Snapshots, reconnection, and history loading carry it +along with its target. A host copying turns into a fork MUST copy each retained +target and its attribution together; if it remaps part IDs, it must also remap +`targetPartId`. Truncating a turn removes both. Merely rendering a citation +does not start an agent turn or change the answer. + +## Text ranges + +`AttributionSpan.range` addresses the target's **raw, final `content` string**, +not rendered Markdown, the combined response, or a source excerpt. + +- Lines and character positions are zero-based. +- Character positions count **UTF-16 code units** within a line, not bytes, + Unicode code points, or displayed characters. +- The start is inclusive and the end is exclusive. Ranges MUST be non-empty. +- CRLF, LF, and lone CR each count as one line break. Line terminators are not + part of a line's character positions. +- Positions MUST lie within the text and MUST NOT split a UTF-16 surrogate + pair. Do not normalize whitespace or Markdown before applying ranges. + +For example, in the JSON string `"\ud83d\ude00 **Fact**\r\nMore"`, `Fact` begins +at line `0`, character `5`, and ends at character `9`. `More` begins at line +`1`, character `0`. The emoji occupies two UTF-16 code units, and the Markdown +markers count even if a client does not display them. + +Clients MAY translate these ranges into their own rendered-text coordinates. +They MUST NOT guess a range when the target or positions cannot be resolved. +They can still show a source list without inline highlights. + +## Sources and locations + +[`AttributionSource`](/reference/chat#attributionsource) can carry a title, URI, +MIME type, short excerpt, and location. None requires a public website or a +local filesystem. A host SHOULD supply a readable title when no URI is +available, and prefer a versioned URI when the source might change. + +An excerpt is an optional quotation from the source, not the agent's own +summary. Hosts SHOULD keep excerpts short instead of embedding entire +documents. A source location addresses the original source, not the excerpt +or generated answer: + +```json +{ "kind": "text", "range": { "start": { "line": 12, "character": 0 }, "end": { "line": 13, "character": 8 } } } +``` + +Text locations use the same line and UTF-16 rules as answer spans. + +```json +{ "kind": "page", "startPage": 4, "endPage": 5 } +``` + +Page locations use one-based document page numbers, with both ends inclusive. +`endPage` MUST be greater than or equal to `startPage`. + +Two sources can share a URI when they describe different passages or page +ranges. Their source IDs remain distinct. A client may group them visually +without merging away the passage information. + +## Compatibility and trust + +`ResponsePartKind` and `AttributionSourceLocationKind` are non-exhaustive. +Clients that do not understand attribution can ignore that part when rendering +and continue displaying the target text. Clients that do not understand a +source-location kind can still display the source title, URI, and excerpt. +No private `_meta` convention is needed for the basic experience. + +Titles and excerpts are untrusted plain text. Source URIs are not permission +grants: clients MUST apply their usual link-opening, resource-access, and +privacy policies. The presence of a source MUST NOT trigger an automatic +fetch. A host MUST only include source details the receiving clients are +allowed to see, including in snapshots and history. + +Attribution records what the host says supports the answer. It does not claim +independent verification, ownership, or a license to reuse the source. +Search planning, ranking, live search progress, and UI layout are outside this +contract. diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index 45a78726..9f8ff0b3 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -302,7 +302,7 @@ Hosts that support the common terminal-command shorthand advertise `InitializeRe ## Response Parts -All response content — text, tool calls, reasoning, and content references — lives in a single `responseParts` array in stream order. This mirrors how LLM APIs (e.g. OpenAI) represent responses as a unified list of typed items. +All response content — text, tool calls, reasoning, content references, and source attribution — lives in a single `responseParts` array in stream order. ```typescript // Inline markdown content @@ -319,6 +319,16 @@ ReasoningResponsePart { content: string } +// Sources supporting an earlier markdown or reasoning part +AttributionResponsePart { + kind: 'attribution' + id: string + targetPartId: string + sources: AttributionSource[] + spans: AttributionSpan[] + _meta?: Record +} + // Tool call (see Tool Call Lifecycle below) ToolCallResponsePart { kind: 'toolCall' @@ -360,6 +370,8 @@ ErrorResponsePart { Text content uses a **create-then-append** pattern: the server first emits a `chat/responsePart` action to create a new markdown or reasoning part with an `id`. `chat/delta` targets markdown parts only, while `chat/reasoning` targets reasoning parts only. This pattern is extensible to future streaming content types. +An optional `attribution` part links ranges in an earlier text part to supporting sources. It arrives after that part's last text delta, so the answer can stream without waiting for source information. It is retained with the turn rather than modifying the answer text. See [Source Attribution](./attribution) for the lifecycle, range rules, and examples. + Clients fetch `ContentRef` content separately via the `resourceRead(uri)` command. This keeps the state tree small and serializable. Consumers can derive display text by concatenating all `markdown` parts, find tool calls by filtering for `toolCall` parts, and access reasoning by filtering for `reasoning` parts. diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 86819567..d1a403cd 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -215,6 +215,19 @@ Ready. Other refinements, such as discovering the MCP customization that owns a server-executed tool, are allowed. Reducers ignore contributor changes that would violate this invariant. +### Source attribution + +The host MAY append an [`AttributionResponsePart`](/reference/chat#attributionresponsepart) +with `chat/responsePart` to identify sources supporting an earlier markdown or +reasoning part in the same turn. The target must have finished streaming. The +host sends attribution before ending the turn; it is retained in `responseParts` +for snapshots, reconnection, and `fetchTurns`. + +Attribution is optional presentation data, not a search command or a permission +to fetch a resource. Clients can ignore it and still render the answer. See +[Source Attribution](/guide/attribution) for the required identifier, position, +and fallback rules. + ### Disposal A chat is implicitly disposed when its owning session is disposed. The protocol does not currently expose a `disposeChat` command; chats live for the life of their session unless the server prunes them. When a chat is removed (whether explicitly or because its session was torn down), the server MUST update the session's `chats` catalog via `session/chatRemoved` so subscribers can release their per-chat subscriptions. diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 70f7a2b5..d74d7bfd 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -5829,6 +5829,150 @@ "content" ] }, + "AttributionResponsePart": { + "type": "object", + "description": "Sources that support an earlier markdown or reasoning part in the same turn.\n\nThe host appends this part with `chat/responsePart` after the target's last\ntext delta and before the turn ends. At most one attribution part may target\na given part. Neither the target text nor its attribution changes afterward,\nincluding when the turn resumes; further output uses new part identifiers.\n\nClients MAY show inline citations or a source list. Clients that do not\nsupport attribution can ignore this part and still render the original text.", + "properties": { + "kind": { + "const": "attribution", + "description": "Discriminant" + }, + "id": { + "type": "string", + "description": "Non-empty identifier, unique among response parts in this turn." + }, + "targetPartId": { + "type": "string", + "description": "Identifier of the earlier MarkdownResponsePart or ReasoningResponsePart." + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/AttributionSource" + }, + "description": "Supporting sources. MUST contain at least one entry, with distinct IDs." + }, + "spans": { + "type": "array", + "items": { + "$ref": "#/$defs/AttributionSpan" + }, + "description": "Ranges of target text linked to sources. An empty list supplies sources for\nthe target as a whole without claiming a more precise text-to-source mapping." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional implementation-specific details; not needed to display attribution." + } + }, + "required": [ + "kind", + "id", + "targetPartId", + "sources", + "spans" + ] + }, + "AttributionSource": { + "type": "object", + "description": "A source, or a passage within a source, supporting an attributed response.\n\nA source need not be a public URL or a local file. Hosts SHOULD provide a\ntitle when no URI is available and prefer versioned URIs when possible.\nTwo entries may share a URI when they describe different passages.", + "properties": { + "id": { + "type": "string", + "description": "Non-empty identifier, unique within the containing attribution part." + }, + "title": { + "type": "string", + "description": "Human-readable source title, rendered as plain text." + }, + "uri": { + "$ref": "#/$defs/URI", + "description": "Source URI. Its presence does not authorize opening or fetching the resource." + }, + "contentType": { + "type": "string", + "description": "MIME type of the source, when known." + }, + "excerpt": { + "type": "string", + "description": "Optional short quotation from the source, not a generated answer summary." + }, + "location": { + "$ref": "#/$defs/AttributionSourceLocation", + "description": "Location within the source, not within the generated response." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional implementation-specific details; not needed to display the source." + } + }, + "required": [ + "id" + ] + }, + "AttributionSpan": { + "type": "object", + "description": "A non-empty range of generated text linked to supporting sources.\n\nRanges address the target part's raw `content`, before Markdown rendering:\nzero-based lines and UTF-16 code-unit character offsets, start inclusive and\nend exclusive. CRLF, LF, and lone CR each count as one line break. Positions\nMUST lie within the text and MUST NOT split a surrogate pair.", + "properties": { + "range": { + "$ref": "#/$defs/TextRange", + "description": "Range within the target part, not within any source or the combined turn." + }, + "sourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-empty, distinct IDs from the containing AttributionResponsePart.sources." + } + }, + "required": [ + "range", + "sourceIds" + ] + }, + "AttributionTextSourceLocation": { + "type": "object", + "description": "A range within a textual source, using the same position rules as AttributionSpan.", + "properties": { + "kind": { + "const": "text" + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "Non-empty, start-inclusive, end-exclusive range within the source text." + } + }, + "required": [ + "kind", + "range" + ] + }, + "AttributionPageSourceLocation": { + "type": "object", + "description": "An inclusive range of pages within a document.", + "properties": { + "kind": { + "const": "page" + }, + "startPage": { + "type": "integer", + "description": "First page, numbered from one.", + "minimum": 1 + }, + "endPage": { + "type": "integer", + "description": "Last page, inclusive. MUST be greater than or equal to startPage.", + "minimum": 1 + } + }, + "required": [ + "kind", + "startPage", + "endPage" + ] + }, "InputRequestResponsePart": { "type": "object", "description": "A live or resolved input request (elicitation) in the turn response stream.\n\nThe server inserts the part with `chat/inputRequested`. While\n{@link response} is absent, clients can update answer drafts with\n`chat/inputAnswerChanged` and submit a response with `chat/inputCompleted`.\nCompletion updates this part in place so its stream position is stable and\nthe full interaction remains durable and backfillable via `fetchTurns`.\n\nIf the turn ends without a submitted response, the unresolved part remains\nin the completed turn transcript with {@link response} absent.", @@ -8338,6 +8482,17 @@ ], "description": "An attachment associated with a {@link Message}." }, + "AttributionSourceLocation": { + "oneOf": [ + { + "$ref": "#/$defs/AttributionTextSourceLocation" + }, + { + "$ref": "#/$defs/AttributionPageSourceLocation" + } + ], + "description": "Where the supporting passage appears within a source. Clients that do not\nrecognize a location kind can still display the source's title, URI, or excerpt." + }, "ResponsePart": { "oneOf": [ { @@ -8360,6 +8515,9 @@ }, { "$ref": "#/$defs/ErrorResponsePart" + }, + { + "$ref": "#/$defs/AttributionResponsePart" } ] }, diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 22902368..d4455cf4 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -5073,6 +5073,150 @@ "content" ] }, + "AttributionResponsePart": { + "type": "object", + "description": "Sources that support an earlier markdown or reasoning part in the same turn.\n\nThe host appends this part with `chat/responsePart` after the target's last\ntext delta and before the turn ends. At most one attribution part may target\na given part. Neither the target text nor its attribution changes afterward,\nincluding when the turn resumes; further output uses new part identifiers.\n\nClients MAY show inline citations or a source list. Clients that do not\nsupport attribution can ignore this part and still render the original text.", + "properties": { + "kind": { + "const": "attribution", + "description": "Discriminant" + }, + "id": { + "type": "string", + "description": "Non-empty identifier, unique among response parts in this turn." + }, + "targetPartId": { + "type": "string", + "description": "Identifier of the earlier MarkdownResponsePart or ReasoningResponsePart." + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/AttributionSource" + }, + "description": "Supporting sources. MUST contain at least one entry, with distinct IDs." + }, + "spans": { + "type": "array", + "items": { + "$ref": "#/$defs/AttributionSpan" + }, + "description": "Ranges of target text linked to sources. An empty list supplies sources for\nthe target as a whole without claiming a more precise text-to-source mapping." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional implementation-specific details; not needed to display attribution." + } + }, + "required": [ + "kind", + "id", + "targetPartId", + "sources", + "spans" + ] + }, + "AttributionSource": { + "type": "object", + "description": "A source, or a passage within a source, supporting an attributed response.\n\nA source need not be a public URL or a local file. Hosts SHOULD provide a\ntitle when no URI is available and prefer versioned URIs when possible.\nTwo entries may share a URI when they describe different passages.", + "properties": { + "id": { + "type": "string", + "description": "Non-empty identifier, unique within the containing attribution part." + }, + "title": { + "type": "string", + "description": "Human-readable source title, rendered as plain text." + }, + "uri": { + "$ref": "#/$defs/URI", + "description": "Source URI. Its presence does not authorize opening or fetching the resource." + }, + "contentType": { + "type": "string", + "description": "MIME type of the source, when known." + }, + "excerpt": { + "type": "string", + "description": "Optional short quotation from the source, not a generated answer summary." + }, + "location": { + "$ref": "#/$defs/AttributionSourceLocation", + "description": "Location within the source, not within the generated response." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional implementation-specific details; not needed to display the source." + } + }, + "required": [ + "id" + ] + }, + "AttributionSpan": { + "type": "object", + "description": "A non-empty range of generated text linked to supporting sources.\n\nRanges address the target part's raw `content`, before Markdown rendering:\nzero-based lines and UTF-16 code-unit character offsets, start inclusive and\nend exclusive. CRLF, LF, and lone CR each count as one line break. Positions\nMUST lie within the text and MUST NOT split a surrogate pair.", + "properties": { + "range": { + "$ref": "#/$defs/TextRange", + "description": "Range within the target part, not within any source or the combined turn." + }, + "sourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-empty, distinct IDs from the containing AttributionResponsePart.sources." + } + }, + "required": [ + "range", + "sourceIds" + ] + }, + "AttributionTextSourceLocation": { + "type": "object", + "description": "A range within a textual source, using the same position rules as AttributionSpan.", + "properties": { + "kind": { + "const": "text" + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "Non-empty, start-inclusive, end-exclusive range within the source text." + } + }, + "required": [ + "kind", + "range" + ] + }, + "AttributionPageSourceLocation": { + "type": "object", + "description": "An inclusive range of pages within a document.", + "properties": { + "kind": { + "const": "page" + }, + "startPage": { + "type": "integer", + "description": "First page, numbered from one.", + "minimum": 1 + }, + "endPage": { + "type": "integer", + "description": "Last page, inclusive. MUST be greater than or equal to startPage.", + "minimum": 1 + } + }, + "required": [ + "kind", + "startPage", + "endPage" + ] + }, "InputRequestResponsePart": { "type": "object", "description": "A live or resolved input request (elicitation) in the turn response stream.\n\nThe server inserts the part with `chat/inputRequested`. While\n{@link response} is absent, clients can update answer drafts with\n`chat/inputAnswerChanged` and submit a response with `chat/inputCompleted`.\nCompletion updates this part in place so its stream position is stable and\nthe full interaction remains durable and backfillable via `fetchTurns`.\n\nIf the turn ends without a submitted response, the unresolved part remains\nin the completed turn transcript with {@link response} absent.", @@ -10379,6 +10523,9 @@ }, { "$ref": "#/$defs/ErrorResponsePart" + }, + { + "$ref": "#/$defs/AttributionResponsePart" } ] }, @@ -10428,6 +10575,17 @@ ], "description": "Discriminated union of all tool call lifecycle states.\n\nSee the [state model guide](/guide/state-model.html#tool-call-lifecycle)\nfor the full state machine diagram." }, + "AttributionSourceLocation": { + "oneOf": [ + { + "$ref": "#/$defs/AttributionTextSourceLocation" + }, + { + "$ref": "#/$defs/AttributionPageSourceLocation" + } + ], + "description": "Where the supporting passage appears within a source. Clients that do not\nrecognize a location kind can still display the source's title, URI, or excerpt." + }, "ChatInputResponseKind": { "enum": [ "accept", diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 71cb746b..3f3b1393 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -3502,6 +3502,150 @@ "content" ] }, + "AttributionResponsePart": { + "type": "object", + "description": "Sources that support an earlier markdown or reasoning part in the same turn.\n\nThe host appends this part with `chat/responsePart` after the target's last\ntext delta and before the turn ends. At most one attribution part may target\na given part. Neither the target text nor its attribution changes afterward,\nincluding when the turn resumes; further output uses new part identifiers.\n\nClients MAY show inline citations or a source list. Clients that do not\nsupport attribution can ignore this part and still render the original text.", + "properties": { + "kind": { + "const": "attribution", + "description": "Discriminant" + }, + "id": { + "type": "string", + "description": "Non-empty identifier, unique among response parts in this turn." + }, + "targetPartId": { + "type": "string", + "description": "Identifier of the earlier MarkdownResponsePart or ReasoningResponsePart." + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/AttributionSource" + }, + "description": "Supporting sources. MUST contain at least one entry, with distinct IDs." + }, + "spans": { + "type": "array", + "items": { + "$ref": "#/$defs/AttributionSpan" + }, + "description": "Ranges of target text linked to sources. An empty list supplies sources for\nthe target as a whole without claiming a more precise text-to-source mapping." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional implementation-specific details; not needed to display attribution." + } + }, + "required": [ + "kind", + "id", + "targetPartId", + "sources", + "spans" + ] + }, + "AttributionSource": { + "type": "object", + "description": "A source, or a passage within a source, supporting an attributed response.\n\nA source need not be a public URL or a local file. Hosts SHOULD provide a\ntitle when no URI is available and prefer versioned URIs when possible.\nTwo entries may share a URI when they describe different passages.", + "properties": { + "id": { + "type": "string", + "description": "Non-empty identifier, unique within the containing attribution part." + }, + "title": { + "type": "string", + "description": "Human-readable source title, rendered as plain text." + }, + "uri": { + "$ref": "#/$defs/URI", + "description": "Source URI. Its presence does not authorize opening or fetching the resource." + }, + "contentType": { + "type": "string", + "description": "MIME type of the source, when known." + }, + "excerpt": { + "type": "string", + "description": "Optional short quotation from the source, not a generated answer summary." + }, + "location": { + "$ref": "#/$defs/AttributionSourceLocation", + "description": "Location within the source, not within the generated response." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional implementation-specific details; not needed to display the source." + } + }, + "required": [ + "id" + ] + }, + "AttributionSpan": { + "type": "object", + "description": "A non-empty range of generated text linked to supporting sources.\n\nRanges address the target part's raw `content`, before Markdown rendering:\nzero-based lines and UTF-16 code-unit character offsets, start inclusive and\nend exclusive. CRLF, LF, and lone CR each count as one line break. Positions\nMUST lie within the text and MUST NOT split a surrogate pair.", + "properties": { + "range": { + "$ref": "#/$defs/TextRange", + "description": "Range within the target part, not within any source or the combined turn." + }, + "sourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-empty, distinct IDs from the containing AttributionResponsePart.sources." + } + }, + "required": [ + "range", + "sourceIds" + ] + }, + "AttributionTextSourceLocation": { + "type": "object", + "description": "A range within a textual source, using the same position rules as AttributionSpan.", + "properties": { + "kind": { + "const": "text" + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "Non-empty, start-inclusive, end-exclusive range within the source text." + } + }, + "required": [ + "kind", + "range" + ] + }, + "AttributionPageSourceLocation": { + "type": "object", + "description": "An inclusive range of pages within a document.", + "properties": { + "kind": { + "const": "page" + }, + "startPage": { + "type": "integer", + "description": "First page, numbered from one.", + "minimum": 1 + }, + "endPage": { + "type": "integer", + "description": "Last page, inclusive. MUST be greater than or equal to startPage.", + "minimum": 1 + } + }, + "required": [ + "kind", + "startPage", + "endPage" + ] + }, "InputRequestResponsePart": { "type": "object", "description": "A live or resolved input request (elicitation) in the turn response stream.\n\nThe server inserts the part with `chat/inputRequested`. While\n{@link response} is absent, clients can update answer drafts with\n`chat/inputAnswerChanged` and submit a response with `chat/inputCompleted`.\nCompletion updates this part in place so its stream position is stable and\nthe full interaction remains durable and backfillable via `fetchTurns`.\n\nIf the turn ends without a submitted response, the unresolved part remains\nin the completed turn transcript with {@link response} absent.", @@ -7717,6 +7861,9 @@ }, { "$ref": "#/$defs/ErrorResponsePart" + }, + { + "$ref": "#/$defs/AttributionResponsePart" } ] }, @@ -7786,6 +7933,17 @@ ], "description": "Discriminated union of all tool call lifecycle states.\n\nSee the [state model guide](/guide/state-model.html#tool-call-lifecycle)\nfor the full state machine diagram." }, + "AttributionSourceLocation": { + "oneOf": [ + { + "$ref": "#/$defs/AttributionTextSourceLocation" + }, + { + "$ref": "#/$defs/AttributionPageSourceLocation" + } + ], + "description": "Where the supporting passage appears within a source. Clients that do not\nrecognize a location kind can still display the source's title, URI, or excerpt." + }, "ChatInputResponseKind": { "enum": [ "accept", diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 01271335..e4b77c3a 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -3669,6 +3669,150 @@ "content" ] }, + "AttributionResponsePart": { + "type": "object", + "description": "Sources that support an earlier markdown or reasoning part in the same turn.\n\nThe host appends this part with `chat/responsePart` after the target's last\ntext delta and before the turn ends. At most one attribution part may target\na given part. Neither the target text nor its attribution changes afterward,\nincluding when the turn resumes; further output uses new part identifiers.\n\nClients MAY show inline citations or a source list. Clients that do not\nsupport attribution can ignore this part and still render the original text.", + "properties": { + "kind": { + "const": "attribution", + "description": "Discriminant" + }, + "id": { + "type": "string", + "description": "Non-empty identifier, unique among response parts in this turn." + }, + "targetPartId": { + "type": "string", + "description": "Identifier of the earlier MarkdownResponsePart or ReasoningResponsePart." + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/AttributionSource" + }, + "description": "Supporting sources. MUST contain at least one entry, with distinct IDs." + }, + "spans": { + "type": "array", + "items": { + "$ref": "#/$defs/AttributionSpan" + }, + "description": "Ranges of target text linked to sources. An empty list supplies sources for\nthe target as a whole without claiming a more precise text-to-source mapping." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional implementation-specific details; not needed to display attribution." + } + }, + "required": [ + "kind", + "id", + "targetPartId", + "sources", + "spans" + ] + }, + "AttributionSource": { + "type": "object", + "description": "A source, or a passage within a source, supporting an attributed response.\n\nA source need not be a public URL or a local file. Hosts SHOULD provide a\ntitle when no URI is available and prefer versioned URIs when possible.\nTwo entries may share a URI when they describe different passages.", + "properties": { + "id": { + "type": "string", + "description": "Non-empty identifier, unique within the containing attribution part." + }, + "title": { + "type": "string", + "description": "Human-readable source title, rendered as plain text." + }, + "uri": { + "$ref": "#/$defs/URI", + "description": "Source URI. Its presence does not authorize opening or fetching the resource." + }, + "contentType": { + "type": "string", + "description": "MIME type of the source, when known." + }, + "excerpt": { + "type": "string", + "description": "Optional short quotation from the source, not a generated answer summary." + }, + "location": { + "$ref": "#/$defs/AttributionSourceLocation", + "description": "Location within the source, not within the generated response." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional implementation-specific details; not needed to display the source." + } + }, + "required": [ + "id" + ] + }, + "AttributionSpan": { + "type": "object", + "description": "A non-empty range of generated text linked to supporting sources.\n\nRanges address the target part's raw `content`, before Markdown rendering:\nzero-based lines and UTF-16 code-unit character offsets, start inclusive and\nend exclusive. CRLF, LF, and lone CR each count as one line break. Positions\nMUST lie within the text and MUST NOT split a surrogate pair.", + "properties": { + "range": { + "$ref": "#/$defs/TextRange", + "description": "Range within the target part, not within any source or the combined turn." + }, + "sourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-empty, distinct IDs from the containing AttributionResponsePart.sources." + } + }, + "required": [ + "range", + "sourceIds" + ] + }, + "AttributionTextSourceLocation": { + "type": "object", + "description": "A range within a textual source, using the same position rules as AttributionSpan.", + "properties": { + "kind": { + "const": "text" + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "Non-empty, start-inclusive, end-exclusive range within the source text." + } + }, + "required": [ + "kind", + "range" + ] + }, + "AttributionPageSourceLocation": { + "type": "object", + "description": "An inclusive range of pages within a document.", + "properties": { + "kind": { + "const": "page" + }, + "startPage": { + "type": "integer", + "description": "First page, numbered from one.", + "minimum": 1 + }, + "endPage": { + "type": "integer", + "description": "Last page, inclusive. MUST be greater than or equal to startPage.", + "minimum": 1 + } + }, + "required": [ + "kind", + "startPage", + "endPage" + ] + }, "InputRequestResponsePart": { "type": "object", "description": "A live or resolved input request (elicitation) in the turn response stream.\n\nThe server inserts the part with `chat/inputRequested`. While\n{@link response} is absent, clients can update answer drafts with\n`chat/inputAnswerChanged` and submit a response with `chat/inputCompleted`.\nCompletion updates this part in place so its stream position is stable and\nthe full interaction remains durable and backfillable via `fetchTurns`.\n\nIf the turn ends without a submitted response, the unresolved part remains\nin the completed turn transcript with {@link response} absent.", @@ -6232,6 +6376,9 @@ }, { "$ref": "#/$defs/ErrorResponsePart" + }, + { + "$ref": "#/$defs/AttributionResponsePart" } ] }, @@ -6301,6 +6448,17 @@ ], "description": "Discriminated union of all tool call lifecycle states.\n\nSee the [state model guide](/guide/state-model.html#tool-call-lifecycle)\nfor the full state machine diagram." }, + "AttributionSourceLocation": { + "oneOf": [ + { + "$ref": "#/$defs/AttributionTextSourceLocation" + }, + { + "$ref": "#/$defs/AttributionPageSourceLocation" + } + ], + "description": "Where the supporting passage appears within a source. Clients that do not\nrecognize a location kind can still display the source's title, URI, or excerpt." + }, "ChatInputResponseKind": { "enum": [ "accept", diff --git a/schema/state.schema.json b/schema/state.schema.json index 097236df..f682e006 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -3413,6 +3413,150 @@ "content" ] }, + "AttributionResponsePart": { + "type": "object", + "description": "Sources that support an earlier markdown or reasoning part in the same turn.\n\nThe host appends this part with `chat/responsePart` after the target's last\ntext delta and before the turn ends. At most one attribution part may target\na given part. Neither the target text nor its attribution changes afterward,\nincluding when the turn resumes; further output uses new part identifiers.\n\nClients MAY show inline citations or a source list. Clients that do not\nsupport attribution can ignore this part and still render the original text.", + "properties": { + "kind": { + "const": "attribution", + "description": "Discriminant" + }, + "id": { + "type": "string", + "description": "Non-empty identifier, unique among response parts in this turn." + }, + "targetPartId": { + "type": "string", + "description": "Identifier of the earlier MarkdownResponsePart or ReasoningResponsePart." + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/AttributionSource" + }, + "description": "Supporting sources. MUST contain at least one entry, with distinct IDs." + }, + "spans": { + "type": "array", + "items": { + "$ref": "#/$defs/AttributionSpan" + }, + "description": "Ranges of target text linked to sources. An empty list supplies sources for\nthe target as a whole without claiming a more precise text-to-source mapping." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional implementation-specific details; not needed to display attribution." + } + }, + "required": [ + "kind", + "id", + "targetPartId", + "sources", + "spans" + ] + }, + "AttributionSource": { + "type": "object", + "description": "A source, or a passage within a source, supporting an attributed response.\n\nA source need not be a public URL or a local file. Hosts SHOULD provide a\ntitle when no URI is available and prefer versioned URIs when possible.\nTwo entries may share a URI when they describe different passages.", + "properties": { + "id": { + "type": "string", + "description": "Non-empty identifier, unique within the containing attribution part." + }, + "title": { + "type": "string", + "description": "Human-readable source title, rendered as plain text." + }, + "uri": { + "$ref": "#/$defs/URI", + "description": "Source URI. Its presence does not authorize opening or fetching the resource." + }, + "contentType": { + "type": "string", + "description": "MIME type of the source, when known." + }, + "excerpt": { + "type": "string", + "description": "Optional short quotation from the source, not a generated answer summary." + }, + "location": { + "$ref": "#/$defs/AttributionSourceLocation", + "description": "Location within the source, not within the generated response." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional implementation-specific details; not needed to display the source." + } + }, + "required": [ + "id" + ] + }, + "AttributionSpan": { + "type": "object", + "description": "A non-empty range of generated text linked to supporting sources.\n\nRanges address the target part's raw `content`, before Markdown rendering:\nzero-based lines and UTF-16 code-unit character offsets, start inclusive and\nend exclusive. CRLF, LF, and lone CR each count as one line break. Positions\nMUST lie within the text and MUST NOT split a surrogate pair.", + "properties": { + "range": { + "$ref": "#/$defs/TextRange", + "description": "Range within the target part, not within any source or the combined turn." + }, + "sourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-empty, distinct IDs from the containing AttributionResponsePart.sources." + } + }, + "required": [ + "range", + "sourceIds" + ] + }, + "AttributionTextSourceLocation": { + "type": "object", + "description": "A range within a textual source, using the same position rules as AttributionSpan.", + "properties": { + "kind": { + "const": "text" + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "Non-empty, start-inclusive, end-exclusive range within the source text." + } + }, + "required": [ + "kind", + "range" + ] + }, + "AttributionPageSourceLocation": { + "type": "object", + "description": "An inclusive range of pages within a document.", + "properties": { + "kind": { + "const": "page" + }, + "startPage": { + "type": "integer", + "description": "First page, numbered from one.", + "minimum": 1 + }, + "endPage": { + "type": "integer", + "description": "Last page, inclusive. MUST be greater than or equal to startPage.", + "minimum": 1 + } + }, + "required": [ + "kind", + "startPage", + "endPage" + ] + }, "InputRequestResponsePart": { "type": "object", "description": "A live or resolved input request (elicitation) in the turn response stream.\n\nThe server inserts the part with `chat/inputRequested`. While\n{@link response} is absent, clients can update answer drafts with\n`chat/inputAnswerChanged` and submit a response with `chat/inputCompleted`.\nCompletion updates this part in place so its stream position is stable and\nthe full interaction remains durable and backfillable via `fetchTurns`.\n\nIf the turn ends without a submitted response, the unresolved part remains\nin the completed turn transcript with {@link response} absent.", @@ -5922,6 +6066,17 @@ ], "description": "An attachment associated with a {@link Message}." }, + "AttributionSourceLocation": { + "oneOf": [ + { + "$ref": "#/$defs/AttributionTextSourceLocation" + }, + { + "$ref": "#/$defs/AttributionPageSourceLocation" + } + ], + "description": "Where the supporting passage appears within a source. Clients that do not\nrecognize a location kind can still display the source's title, URI, or excerpt." + }, "ResponsePart": { "oneOf": [ { @@ -5944,6 +6099,9 @@ }, { "$ref": "#/$defs/ErrorResponsePart" + }, + { + "$ref": "#/$defs/AttributionResponsePart" } ] }, diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts index 20721221..79dcd6c0 100644 --- a/scripts/generate-csharp.ts +++ b/scripts/generate-csharp.ts @@ -636,6 +636,7 @@ const STATE_ENUMS = [ 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', + 'AttributionSourceLocationKind', 'ToolCallConfirmationReason', 'ToolCallCancellationReason', 'ToolCallRiskAssessmentKind', 'ToolCallRiskAssessmentStatus', 'ConfirmationOptionKind', @@ -714,6 +715,11 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: strin { name: 'SystemNotificationResponsePart' }, { name: 'InputRequestResponsePart' }, { name: 'ErrorResponsePart' }, + { name: 'AttributionResponsePart' }, + { name: 'AttributionSource' }, + { name: 'AttributionSpan' }, + { name: 'AttributionTextSourceLocation' }, + { name: 'AttributionPageSourceLocation' }, { name: 'ToolCallResult' }, { name: 'ConfirmationOption' }, { name: 'ToolCallRiskAssessmentLoadingState' }, @@ -817,6 +823,18 @@ const RESPONSE_PART_UNION: UnionConfig = { { variantName: 'SystemNotification', innerType: 'SystemNotificationResponsePart', wireValue: 'systemNotification' }, { variantName: 'InputRequest', innerType: 'InputRequestResponsePart', wireValue: 'inputRequest' }, { variantName: 'Error', innerType: 'ErrorResponsePart', wireValue: 'error' }, + { variantName: 'Attribution', innerType: 'AttributionResponsePart', wireValue: 'attribution' }, + ], + unknown: true, +}; + +const ATTRIBUTION_SOURCE_LOCATION_UNION: UnionConfig = { + name: 'AttributionSourceLocation', + discriminantField: 'kind', + doc: 'A location within a source supporting an attributed response.', + variants: [ + { variantName: 'Text', innerType: 'AttributionTextSourceLocation', wireValue: 'text' }, + { variantName: 'Page', innerType: 'AttributionPageSourceLocation', wireValue: 'page' }, ], unknown: true, }; @@ -1384,7 +1402,7 @@ function generateStateFile(project: Project): string { lines.push(CUSTOMIZATION_ENABLEMENT_UNION_CS); lines.push(''); for (const u of [ - RESPONSE_PART_UNION, TOOL_CALL_STATE_UNION, TOOL_CALL_CONFIRMATION_STATE_UNION, + RESPONSE_PART_UNION, ATTRIBUTION_SOURCE_LOCATION_UNION, TOOL_CALL_STATE_UNION, TOOL_CALL_CONFIRMATION_STATE_UNION, TOOL_CALL_RISK_ASSESSMENT_UNION, TERMINAL_CLAIM_UNION, TERMINAL_CONTENT_PART_UNION, CHAT_INPUT_QUESTION_UNION, CHAT_INPUT_ANSWER_VALUE_UNION, CHAT_INPUT_ANSWER_UNION, @@ -2552,7 +2570,7 @@ function checkExhaustiveness(project: Project): void { const knownSpecial = new Set([ 'URI', 'JsonPrimitive', 'BaseParams', 'StringOrMarkdown', 'ToolCallState', 'StateAction', - 'ActionEnvelope', 'ActionOrigin', 'ResponsePart', 'ToolResultContent', + 'ActionEnvelope', 'ActionOrigin', 'ResponsePart', 'AttributionSourceLocation', 'ToolResultContent', 'SessionToolCallApprovedAction', 'SessionToolCallDeniedAction', 'SessionToolCallConfirmedAction', 'PingParams', 'TerminalClaim', 'TerminalContentPart', 'MessageAttachment', 'MessageAttachmentBase', diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 71926ac2..efcacdd5 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -717,6 +717,7 @@ const STATE_ENUMS = [ 'ChatOriginKind', 'ChatInteractivity', 'PendingMessageKind', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', + 'AttributionSourceLocationKind', 'ToolCallConfirmationReason', 'ToolCallRiskAssessmentKind', 'ToolCallRiskAssessmentStatus', 'ToolCallCancellationReason', @@ -794,6 +795,11 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'SystemNotificationResponsePart' }, { name: 'InputRequestResponsePart' }, { name: 'ErrorResponsePart' }, + { name: 'AttributionResponsePart' }, + { name: 'AttributionSource' }, + { name: 'AttributionSpan' }, + { name: 'AttributionTextSourceLocation' }, + { name: 'AttributionPageSourceLocation' }, { name: 'ToolCallResult' }, { name: 'ToolCallRiskAssessmentLoadingState' }, { name: 'ToolCallRiskAssessmentCompleteState' }, @@ -897,6 +903,18 @@ const RESPONSE_PART_UNION: UnionConfig = { { variantName: 'SystemNotification', innerType: 'SystemNotificationResponsePart', wireValue: 'systemNotification' }, { variantName: 'InputRequest', innerType: 'InputRequestResponsePart', wireValue: 'inputRequest' }, { variantName: 'Error', innerType: 'ErrorResponsePart', wireValue: 'error' }, + { variantName: 'Attribution', innerType: 'AttributionResponsePart', wireValue: 'attribution' }, + ], + unknown: true, +}; + +const ATTRIBUTION_SOURCE_LOCATION_UNION: UnionConfig = { + name: 'AttributionSourceLocation', + discriminantField: 'kind', + doc: 'AttributionSourceLocation identifies a passage within a supporting source.', + variants: [ + { variantName: 'Text', innerType: 'AttributionTextSourceLocation', wireValue: 'text' }, + { variantName: 'Page', innerType: 'AttributionPageSourceLocation', wireValue: 'page' }, ], unknown: true, }; @@ -1457,6 +1475,8 @@ function generateStateFile(project: Project): string { lines.push('// ─── Discriminated Unions ─────────────────────────────────────────────\n'); lines.push(generateDiscriminatedUnion(project, RESPONSE_PART_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, ATTRIBUTION_SOURCE_LOCATION_UNION)); + lines.push(''); lines.push(generateDiscriminatedUnion(project, TOOL_CALL_STATE_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, TOOL_CALL_CONFIRMATION_STATE_UNION)); @@ -2289,6 +2309,7 @@ function checkExhaustiveness(project: Project): void { 'ActionEnvelope', 'ActionOrigin', 'ResponsePart', + 'AttributionSourceLocation', 'ToolResultContent', 'SessionToolCallApprovedAction', 'SessionToolCallDeniedAction', diff --git a/scripts/generate-json-schema.test.ts b/scripts/generate-json-schema.test.ts index 53985809..579ce26b 100644 --- a/scripts/generate-json-schema.test.ts +++ b/scripts/generate-json-schema.test.ts @@ -173,6 +173,48 @@ describe('generated JSON schemas', () => { ); }); + it('represents answer attribution as a typed response part', () => { + const defs = schema.$defs as Record>; + const part = defs.AttributionResponsePart; + assert.ok(part, `${file} must define AttributionResponsePart`); + const properties = part.properties as Record>; + assert.equal(properties.kind.const, 'attribution'); + assert.deepEqual(part.required, ['kind', 'id', 'targetPartId', 'sources', 'spans']); + assert.ok(schemaAccepts(schema, defs.ResponsePart as JsonNode, { + kind: 'attribution', + id: 'attribution-1', + targetPartId: 'answer-1', + sources: [{ id: 'source-1', uri: 'https://example.org/history' }], + spans: [{ + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 6 } }, + sourceIds: ['source-1'], + }], + })); + assert.equal(schemaAccepts(schema, part as JsonNode, { + kind: 'attribution', id: 'attribution-1', sources: [], spans: [], + }), false, 'an attribution must identify the text it describes'); + }); + + it('distinguishes text and page locations in attribution sources', () => { + const defs = schema.$defs as Record>; + const location = defs.AttributionSourceLocation; + assert.ok(location, `${file} must define AttributionSourceLocation`); + assert.ok(schemaAccepts(schema, location as JsonNode, { + kind: 'text', + range: { start: { line: 2, character: 0 }, end: { line: 3, character: 12 } }, + })); + assert.ok(schemaAccepts(schema, location as JsonNode, { + kind: 'page', startPage: 4, endPage: 5, + })); + assert.equal(schemaAccepts(schema, location as JsonNode, { kind: 'text' }), false); + assert.equal(schemaAccepts(schema, location as JsonNode, { kind: 'page', startPage: 4 }), false); + const pageProperties = defs.AttributionPageSourceLocation.properties as Record>; + for (const name of ['startPage', 'endPage']) { + assert.equal(pageProperties[name].type, 'integer'); + assert.equal(pageProperties[name].minimum, 1); + } + }); + it('preserves automation schedule restrictions', () => { if (file !== 'commands.schema.json') { return; diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index ea13b41e..39d8a2cf 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -969,6 +969,7 @@ const STATE_ENUMS = [ 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', + 'AttributionSourceLocationKind', 'ToolCallConfirmationReason', 'ToolCallRiskAssessmentKind', 'ToolCallRiskAssessmentStatus', 'ToolCallCancellationReason', 'ConfirmationOptionKind', @@ -1009,6 +1010,8 @@ const STATE_STRUCTS = [ 'ResourceResponsePart', 'ToolCallResponsePart', 'ReasoningResponsePart', 'SystemNotificationResponsePart', 'InputRequestResponsePart', 'ErrorResponsePart', + 'AttributionResponsePart', 'AttributionSource', 'AttributionSpan', + 'AttributionTextSourceLocation', 'AttributionPageSourceLocation', 'ToolCallResult', 'ToolCallStreamingState', 'ToolCallPendingConfirmationState', 'ToolCallRunningState', 'ToolCallAuthRequiredState', 'ToolCallPendingResultConfirmationState', 'ToolCallCompletedState', @@ -1061,6 +1064,17 @@ const RESPONSE_PART_UNION: UnionConfig = { { caseName: 'SystemNotification', structName: 'SystemNotificationResponsePart', discriminantValue: 'systemNotification' }, { caseName: 'InputRequest', structName: 'InputRequestResponsePart', discriminantValue: 'inputRequest' }, { caseName: 'Error', structName: 'ErrorResponsePart', discriminantValue: 'error' }, + { caseName: 'Attribution', structName: 'AttributionResponsePart', discriminantValue: 'attribution' }, + ], + unknown: true, +}; + +const ATTRIBUTION_SOURCE_LOCATION_UNION: UnionConfig = { + name: 'AttributionSourceLocation', + discriminantField: 'kind', + variants: [ + { caseName: 'Text', structName: 'AttributionTextSourceLocation', discriminantValue: 'text' }, + { caseName: 'Page', structName: 'AttributionPageSourceLocation', discriminantValue: 'page' }, ], unknown: true, }; @@ -1414,6 +1428,8 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(project, RESPONSE_PART_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, ATTRIBUTION_SOURCE_LOCATION_UNION)); + lines.push(''); lines.push(generateDiscriminatedUnion(project, TOOL_CALL_STATE_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, TOOL_CALL_CONFIRMATION_STATE_UNION)); @@ -2325,6 +2341,7 @@ function checkExhaustiveness(project: Project): void { 'ActionEnvelope', // generateDataClassFromInterface() call in generateActionsFile() 'ActionOrigin', // generateDataClassFromInterface() call in generateActionsFile() 'ResponsePart', // RESPONSE_PART_UNION discriminated union + 'AttributionSourceLocation', 'ToolResultContent', // generateToolResultContentUnion() 'SessionToolCallApprovedAction', // merged into SessionToolCallConfirmedAction 'SessionToolCallDeniedAction', // merged into SessionToolCallConfirmedAction diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 4e999bbc..c6bdff26 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -757,6 +757,7 @@ const STATE_ENUMS = [ 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', + 'AttributionSourceLocationKind', 'ToolCallConfirmationReason', 'ToolCallRiskAssessmentKind', 'ToolCallRiskAssessmentStatus', 'ToolCallCancellationReason', @@ -855,6 +856,11 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'SystemNotificationResponsePart', omitDiscriminants: true }, { name: 'InputRequestResponsePart', omitDiscriminants: true }, { name: 'ErrorResponsePart', omitDiscriminants: true }, + { name: 'AttributionResponsePart', omitDiscriminants: true }, + { name: 'AttributionSource' }, + { name: 'AttributionSpan' }, + { name: 'AttributionTextSourceLocation', omitDiscriminants: true }, + { name: 'AttributionPageSourceLocation', omitDiscriminants: true }, { name: 'ToolCallResult' }, { name: 'ToolCallRiskAssessmentLoadingState', omitDiscriminants: true }, { name: 'ToolCallRiskAssessmentCompleteState', omitDiscriminants: true }, @@ -958,6 +964,18 @@ const RESPONSE_PART_UNION: UnionConfig = { { variantName: 'SystemNotification', innerType: 'SystemNotificationResponsePart', wireValue: 'systemNotification' }, { variantName: 'InputRequest', innerType: 'InputRequestResponsePart', wireValue: 'inputRequest' }, { variantName: 'Error', innerType: 'ErrorResponsePart', wireValue: 'error' }, + { variantName: 'Attribution', innerType: 'AttributionResponsePart', wireValue: 'attribution' }, + ], + unknown: true, +}; + +const ATTRIBUTION_SOURCE_LOCATION_UNION: UnionConfig = { + name: 'AttributionSourceLocation', + discriminantField: 'kind', + doc: 'A location within a source supporting an attributed response.', + variants: [ + { variantName: 'Text', innerType: 'AttributionTextSourceLocation', wireValue: 'text' }, + { variantName: 'Page', innerType: 'AttributionPageSourceLocation', wireValue: 'page' }, ], unknown: true, }; @@ -1359,6 +1377,8 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(project, RESPONSE_PART_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, ATTRIBUTION_SOURCE_LOCATION_UNION)); + lines.push(''); lines.push(generateDiscriminatedUnion(project, TOOL_CALL_STATE_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, TOOL_CALL_CONFIRMATION_STATE_UNION)); @@ -2196,6 +2216,7 @@ function checkExhaustiveness(project: Project): void { 'ActionEnvelope', 'ActionOrigin', 'ResponsePart', + 'AttributionSourceLocation', 'ToolResultContent', 'SessionToolCallApprovedAction', 'SessionToolCallDeniedAction', diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 89fa9bcd..6241a368 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -674,6 +674,7 @@ const STATE_ENUMS = [ 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', + 'AttributionSourceLocationKind', 'ToolCallConfirmationReason', 'ToolCallRiskAssessmentKind', 'ToolCallRiskAssessmentStatus', 'ToolCallCancellationReason', 'ConfirmationOptionKind', @@ -714,6 +715,8 @@ const STATE_STRUCTS = [ 'ResourceResponsePart', 'ToolCallResponsePart', 'ReasoningResponsePart', 'SystemNotificationResponsePart', 'InputRequestResponsePart', 'ErrorResponsePart', + 'AttributionResponsePart', 'AttributionSource', 'AttributionSpan', + 'AttributionTextSourceLocation', 'AttributionPageSourceLocation', 'ToolCallResult', 'ToolCallStreamingState', 'ToolCallPendingConfirmationState', 'ToolCallRunningState', 'ToolCallAuthRequiredState', 'ToolCallPendingResultConfirmationState', 'ToolCallCompletedState', @@ -771,6 +774,17 @@ const RESPONSE_PART_UNION: UnionConfig = { { caseName: 'systemNotification', structName: 'SystemNotificationResponsePart', discriminantValue: 'systemNotification' }, { caseName: 'inputRequest', structName: 'InputRequestResponsePart', discriminantValue: 'inputRequest' }, { caseName: 'error', structName: 'ErrorResponsePart', discriminantValue: 'error' }, + { caseName: 'attribution', structName: 'AttributionResponsePart', discriminantValue: 'attribution' }, + ], +}; + +const ATTRIBUTION_SOURCE_LOCATION_UNION: UnionConfig = { + name: 'AttributionSourceLocation', + discriminantField: 'kind', + allowUnknown: true, + variants: [ + { caseName: 'text', structName: 'AttributionTextSourceLocation', discriminantValue: 'text' }, + { caseName: 'page', structName: 'AttributionPageSourceLocation', discriminantValue: 'page' }, ], }; @@ -1310,6 +1324,8 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(project, RESPONSE_PART_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, ATTRIBUTION_SOURCE_LOCATION_UNION)); + lines.push(''); lines.push(generateDiscriminatedUnion(project, TOOL_CALL_STATE_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, TOOL_CALL_CONFIRMATION_STATE_UNION)); @@ -2341,6 +2357,7 @@ function checkExhaustiveness(project: Project): void { 'ActionEnvelope', // generateStructFromInterface() call in generateActionsFile() 'ActionOrigin', // generateStructFromInterface() call in generateActionsFile() 'ResponsePart', // RESPONSE_PART_UNION discriminated union + 'AttributionSourceLocation', 'ToolResultContent', // TOOL_RESULT_CONTENT_UNION discriminated union 'SessionToolCallApprovedAction', // merged into SessionToolCallConfirmedAction 'SessionToolCallDeniedAction', // merged into SessionToolCallConfirmedAction diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index a2609165..c9e034bb 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -877,6 +877,7 @@ export const enum ResponsePartKind { SystemNotification = 'systemNotification', InputRequest = 'inputRequest', Error = 'error', + Attribution = 'attribution', } /** @@ -931,6 +932,133 @@ export interface ReasoningResponsePart { content: string; } +/** + * Sources that support an earlier markdown or reasoning part in the same turn. + * + * The host appends this part with `chat/responsePart` after the target's last + * text delta and before the turn ends. At most one attribution part may target + * a given part. Neither the target text nor its attribution changes afterward, + * including when the turn resumes; further output uses new part identifiers. + * + * Clients MAY show inline citations or a source list. Clients that do not + * support attribution can ignore this part and still render the original text. + * + * @category Response Parts + */ +export interface AttributionResponsePart { + /** Discriminant */ + kind: ResponsePartKind.Attribution; + /** Non-empty identifier, unique among response parts in this turn. */ + id: string; + /** Identifier of the earlier MarkdownResponsePart or ReasoningResponsePart. */ + targetPartId: string; + /** Supporting sources. MUST contain at least one entry, with distinct IDs. */ + sources: AttributionSource[]; + /** + * Ranges of target text linked to sources. An empty list supplies sources for + * the target as a whole without claiming a more precise text-to-source mapping. + */ + spans: AttributionSpan[]; + /** Optional implementation-specific details; not needed to display attribution. */ + _meta?: Record; +} + +/** + * A source, or a passage within a source, supporting an attributed response. + * + * A source need not be a public URL or a local file. Hosts SHOULD provide a + * title when no URI is available and prefer versioned URIs when possible. + * Two entries may share a URI when they describe different passages. + * + * @category Response Parts + */ +export interface AttributionSource { + /** Non-empty identifier, unique within the containing attribution part. */ + id: string; + /** Human-readable source title, rendered as plain text. */ + title?: string; + /** Source URI. Its presence does not authorize opening or fetching the resource. */ + uri?: URI; + /** MIME type of the source, when known. */ + contentType?: string; + /** Optional short quotation from the source, not a generated answer summary. */ + excerpt?: string; + /** Location within the source, not within the generated response. */ + location?: AttributionSourceLocation; + /** Optional implementation-specific details; not needed to display the source. */ + _meta?: Record; +} + +/** + * A non-empty range of generated text linked to supporting sources. + * + * Ranges address the target part's raw `content`, before Markdown rendering: + * zero-based lines and UTF-16 code-unit character offsets, start inclusive and + * end exclusive. CRLF, LF, and lone CR each count as one line break. Positions + * MUST lie within the text and MUST NOT split a surrogate pair. + * + * @category Response Parts + */ +export interface AttributionSpan { + /** Range within the target part, not within any source or the combined turn. */ + range: TextRange; + /** Non-empty, distinct IDs from the containing AttributionResponsePart.sources. */ + sourceIds: string[]; +} + +/** + * The kind of location within an attribution source. + * + * @category Response Parts + * @nonexhaustive + */ +export const enum AttributionSourceLocationKind { + Text = 'text', + Page = 'page', +} + +/** + * A range within a textual source, using the same position rules as AttributionSpan. + * + * @category Response Parts + */ +export interface AttributionTextSourceLocation { + kind: AttributionSourceLocationKind.Text; + /** Non-empty, start-inclusive, end-exclusive range within the source text. */ + range: TextRange; +} + +/** + * An inclusive range of pages within a document. + * + * @category Response Parts + */ +export interface AttributionPageSourceLocation { + kind: AttributionSourceLocationKind.Page; + /** + * First page, numbered from one. + * @integer + * @minimum 1 + */ + startPage: number; + /** + * Last page, inclusive. MUST be greater than or equal to startPage. + * @integer + * @minimum 1 + */ + endPage: number; +} + +/** + * Where the supporting passage appears within a source. Clients that do not + * recognize a location kind can still display the source's title, URI, or excerpt. + * + * @category Response Parts + */ +export type AttributionSourceLocation = + | AttributionTextSourceLocation + | AttributionPageSourceLocation; + /** * @category Response Parts */ @@ -941,7 +1069,8 @@ export type ResponsePart = | ReasoningResponsePart | SystemNotificationResponsePart | InputRequestResponsePart - | ErrorResponsePart; + | ErrorResponsePart + | AttributionResponsePart; /** * A live or resolved input request (elicitation) in the turn response stream. diff --git a/types/test-cases/reducers/271-chat-attribution-after-streaming.json b/types/test-cases/reducers/271-chat-attribution-after-streaming.json new file mode 100644 index 00000000..dea08c72 --- /dev/null +++ b/types/test-cases/reducers/271-chat-attribution-after-streaming.json @@ -0,0 +1,133 @@ +{ + "description": "attribution arrives after streamed Unicode Markdown and survives turn completion unchanged", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Library history", + "status": 1, + "modifiedAt": "2026-09-16T00:00:00.000Z", + "turns": [] + }, + "actions": [ + { + "type": "chat/turnStarted", + "turnId": "turn-1", + "startedAt": "2026-09-16T00:00:00.000Z", + "message": { "text": "When did the library open?", "origin": { "kind": "user" } } + }, + { + "type": "chat/responsePart", + "turnId": "turn-1", + "part": { "kind": "markdown", "id": "answer-1", "content": "" } + }, + { + "type": "chat/delta", + "turnId": "turn-1", + "partId": "answer-1", + "content": "\ud83d\ude00 **The library**\r\n" + }, + { + "type": "chat/delta", + "turnId": "turn-1", + "partId": "answer-1", + "content": "opened in 1984." + }, + { + "type": "chat/responsePart", + "turnId": "turn-1", + "part": { + "kind": "attribution", + "id": "attribution-1", + "targetPartId": "answer-1", + "sources": [ + { + "id": "history", + "title": "Library history", + "uri": "https://example.org/library/history", + "contentType": "text/html", + "excerpt": "The library opened in 1984.", + "location": { + "kind": "text", + "range": { "start": { "line": 3, "character": 0 }, "end": { "line": 3, "character": 27 } } + }, + "_meta": { "example.org/source": "archive-42" } + }, + { + "id": "guide", + "title": "Uploaded library guide", + "contentType": "application/pdf", + "excerpt": "Opening year: 1984", + "location": { "kind": "page", "startPage": 4, "endPage": 4 } + } + ], + "spans": [ + { + "range": { "start": { "line": 0, "character": 3 }, "end": { "line": 1, "character": 15 } }, + "sourceIds": ["history"] + }, + { + "range": { "start": { "line": 1, "character": 10 }, "end": { "line": 1, "character": 14 } }, + "sourceIds": ["history", "guide"] + } + ], + "_meta": { "example.org/attribution": { "version": 1 } } + } + }, + { "type": "chat/turnComplete", "turnId": "turn-1", "duration": 1500 } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Library history", + "status": 1, + "modifiedAt": "2026-09-16T00:00:01.500Z", + "activeTurn": null, + "turns": [{ + "id": "turn-1", + "startedAt": "2026-09-16T00:00:00.000Z", + "duration": 1500, + "message": { "text": "When did the library open?", "origin": { "kind": "user" } }, + "responseParts": [ + { "kind": "markdown", "id": "answer-1", "content": "\ud83d\ude00 **The library**\r\nopened in 1984." }, + { + "kind": "attribution", + "id": "attribution-1", + "targetPartId": "answer-1", + "sources": [ + { + "id": "history", + "title": "Library history", + "uri": "https://example.org/library/history", + "contentType": "text/html", + "excerpt": "The library opened in 1984.", + "location": { + "kind": "text", + "range": { "start": { "line": 3, "character": 0 }, "end": { "line": 3, "character": 27 } } + }, + "_meta": { "example.org/source": "archive-42" } + }, + { + "id": "guide", + "title": "Uploaded library guide", + "contentType": "application/pdf", + "excerpt": "Opening year: 1984", + "location": { "kind": "page", "startPage": 4, "endPage": 4 } + } + ], + "spans": [ + { + "range": { "start": { "line": 0, "character": 3 }, "end": { "line": 1, "character": 15 } }, + "sourceIds": ["history"] + }, + { + "range": { "start": { "line": 1, "character": 10 }, "end": { "line": 1, "character": 14 } }, + "sourceIds": ["history", "guide"] + } + ], + "_meta": { "example.org/attribution": { "version": 1 } } + } + ], + "usage": null, + "state": "complete" + }] + } +} diff --git a/types/test-cases/reducers/272-chat-attribution-in-loaded-history.json b/types/test-cases/reducers/272-chat-attribution-in-loaded-history.json new file mode 100644 index 00000000..117b27a7 --- /dev/null +++ b/types/test-cases/reducers/272-chat-attribution-in-loaded-history.json @@ -0,0 +1,95 @@ +{ + "description": "loading older turns preserves whole-answer attribution and distinct passages from the same URI", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "History", + "status": 1, + "modifiedAt": "2026-09-16T00:00:02.000Z", + "turnsNextCursor": "older", + "turns": [{ + "id": "turn-2", + "message": { "text": "Thanks", "origin": { "kind": "user" } }, + "responseParts": [], + "usage": null, + "state": "complete" + }] + }, + "actions": [{ + "type": "chat/turnsLoaded", + "turns": [{ + "id": "turn-1", + "message": { "text": "Summarize the guide", "origin": { "kind": "user" } }, + "responseParts": [ + { "kind": "markdown", "id": "answer-1", "content": "The guide describes two buildings." }, + { + "kind": "attribution", + "id": "attribution-1", + "targetPartId": "answer-1", + "sources": [ + { + "id": "first", + "uri": "https://example.org/guide.pdf", + "excerpt": "The first building", + "location": { "kind": "page", "startPage": 1, "endPage": 2 } + }, + { + "id": "second", + "uri": "https://example.org/guide.pdf", + "excerpt": "The second building", + "location": { "kind": "page", "startPage": 8, "endPage": 9 } + } + ], + "spans": [] + } + ], + "usage": null, + "state": "complete" + }] + }], + "expected": { + "resource": "ahp-chat:/c1", + "title": "History", + "status": 1, + "modifiedAt": "2026-09-16T00:00:02.000Z", + "turnsNextCursor": null, + "turns": [ + { + "id": "turn-1", + "message": { "text": "Summarize the guide", "origin": { "kind": "user" } }, + "responseParts": [ + { "kind": "markdown", "id": "answer-1", "content": "The guide describes two buildings." }, + { + "kind": "attribution", + "id": "attribution-1", + "targetPartId": "answer-1", + "sources": [ + { + "id": "first", + "uri": "https://example.org/guide.pdf", + "excerpt": "The first building", + "location": { "kind": "page", "startPage": 1, "endPage": 2 } + }, + { + "id": "second", + "uri": "https://example.org/guide.pdf", + "excerpt": "The second building", + "location": { "kind": "page", "startPage": 8, "endPage": 9 } + } + ], + "spans": [] + } + ], + "usage": null, + "state": "complete" + }, + { + "id": "turn-2", + "message": { "text": "Thanks", "origin": { "kind": "user" } }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ] + } +} diff --git a/types/test-cases/reducers/273-chat-attribution-is-not-streamed-text.json b/types/test-cases/reducers/273-chat-attribution-is-not-streamed-text.json new file mode 100644 index 00000000..fc27f90c --- /dev/null +++ b/types/test-cases/reducers/273-chat-attribution-is-not-streamed-text.json @@ -0,0 +1,60 @@ +{ + "description": "text deltas never alter attribution while later answer parts keep streaming", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Answer", + "status": 8, + "modifiedAt": "2026-09-16T00:00:00.000Z", + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "2026-09-16T00:00:00.000Z", + "message": { "text": "Explain", "origin": { "kind": "user" } }, + "responseParts": [ + { "kind": "reasoning", "id": "reasoning-1", "content": "The reference agrees." }, + { + "kind": "attribution", + "id": "attribution-1", + "targetPartId": "reasoning-1", + "sources": [{ "id": "reference", "title": "Uploaded reference" }], + "spans": [] + }, + { "kind": "markdown", "id": "answer-1", "content": "" } + ], + "usage": null + } + }, + "actions": [ + { "type": "chat/delta", "turnId": "turn-1", "partId": "attribution-1", "content": "not text" }, + { "type": "chat/reasoning", "turnId": "turn-1", "partId": "attribution-1", "content": "not reasoning" }, + { "type": "chat/delta", "turnId": "turn-1", "partId": "answer-1", "content": "Here is the answer." }, + { "type": "chat/turnCancelled", "turnId": "turn-1", "duration": 1000 } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Answer", + "status": 1, + "modifiedAt": "2026-09-16T00:00:01.000Z", + "activeTurn": null, + "turns": [{ + "id": "turn-1", + "startedAt": "2026-09-16T00:00:00.000Z", + "duration": 1000, + "message": { "text": "Explain", "origin": { "kind": "user" } }, + "responseParts": [ + { "kind": "reasoning", "id": "reasoning-1", "content": "The reference agrees." }, + { + "kind": "attribution", + "id": "attribution-1", + "targetPartId": "reasoning-1", + "sources": [{ "id": "reference", "title": "Uploaded reference" }], + "spans": [] + }, + { "kind": "markdown", "id": "answer-1", "content": "Here is the answer." } + ], + "usage": null, + "state": "cancelled" + }] + } +} diff --git a/types/test-cases/reducers/274-chat-attribution-survives-error-and-resume.json b/types/test-cases/reducers/274-chat-attribution-survives-error-and-resume.json new file mode 100644 index 00000000..7d97e52b --- /dev/null +++ b/types/test-cases/reducers/274-chat-attribution-survives-error-and-resume.json @@ -0,0 +1,105 @@ +{ + "description": "attribution survives an error and resume while further output uses a new text part", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "History", + "status": 8, + "modifiedAt": "2026-09-16T00:00:00.000Z", + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "2026-09-16T00:00:00.000Z", + "message": { "text": "Explain the history", "origin": { "kind": "user" } }, + "responseParts": [ + { "kind": "markdown", "id": "answer-1", "content": "The library opened in 1984." } + ], + "usage": null + } + }, + "actions": [ + { + "type": "chat/responsePart", + "turnId": "turn-1", + "part": { + "kind": "attribution", + "id": "attribution-1", + "targetPartId": "answer-1", + "sources": [{ "id": "history", "uri": "https://example.org/history" }], + "spans": [] + } + }, + { + "type": "chat/error", + "turnId": "turn-1", + "duration": 1000, + "part": { + "kind": "error", + "error": { "errorType": "runtime", "message": "Connection interrupted" }, + "resumable": true + } + }, + { "type": "chat/turnResume", "turnId": "turn-1" }, + { + "type": "chat/responsePart", + "turnId": "turn-1", + "part": { "kind": "markdown", "id": "answer-2", "content": "" } + }, + { + "type": "chat/delta", + "turnId": "turn-1", + "partId": "answer-2", + "content": "It expanded in 2001." + }, + { + "type": "chat/responsePart", + "turnId": "turn-1", + "part": { + "kind": "attribution", + "id": "attribution-2", + "targetPartId": "answer-2", + "sources": [{ "id": "history", "uri": "https://example.org/expansion" }], + "spans": [] + } + }, + { "type": "chat/turnComplete", "turnId": "turn-1", "duration": 2000 } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "History", + "status": 1, + "modifiedAt": "2026-09-16T00:00:02.000Z", + "activeTurn": null, + "turns": [{ + "id": "turn-1", + "startedAt": "2026-09-16T00:00:00.000Z", + "duration": 2000, + "message": { "text": "Explain the history", "origin": { "kind": "user" } }, + "responseParts": [ + { "kind": "markdown", "id": "answer-1", "content": "The library opened in 1984." }, + { + "kind": "attribution", + "id": "attribution-1", + "targetPartId": "answer-1", + "sources": [{ "id": "history", "uri": "https://example.org/history" }], + "spans": [] + }, + { + "kind": "error", + "error": { "errorType": "runtime", "message": "Connection interrupted" }, + "resumable": true + }, + { "kind": "markdown", "id": "answer-2", "content": "It expanded in 2001." }, + { + "kind": "attribution", + "id": "attribution-2", + "targetPartId": "answer-2", + "sources": [{ "id": "history", "uri": "https://example.org/expansion" }], + "spans": [] + } + ], + "usage": null, + "state": "complete" + }] + } +} diff --git a/types/test-cases/round-trips/045-response-attribution-action.json b/types/test-cases/round-trips/045-response-attribution-action.json new file mode 100644 index 00000000..d182da64 --- /dev/null +++ b/types/test-cases/round-trips/045-response-attribution-action.json @@ -0,0 +1,94 @@ +{ + "name": "response-attribution-action", + "group": "A", + "description": "Attribution round-trips through a response-part action with source locations, overlapping spans, and optional metadata.", + "type": "ActionEnvelope", + "input": { + "channel": "ahp-chat:/c1", + "serverSeq": 12, + "action": { + "type": "chat/responsePart", + "turnId": "turn-1", + "part": { + "kind": "attribution", + "id": "attribution-1", + "targetPartId": "answer-1", + "sources": [ + { + "id": "file", + "title": "History", + "uri": "file:///workspace/history.txt", + "contentType": "text/plain", + "excerpt": "Opened in 1984.", + "location": { + "kind": "text", + "range": { "start": { "line": 2, "character": 0 }, "end": { "line": 2, "character": 15 } } + }, + "_meta": { "example.org/revision": "v1" } + }, + { + "id": "document", + "title": "Uploaded guide", + "contentType": "application/pdf", + "location": { "kind": "page", "startPage": 4, "endPage": 5 } + } + ], + "spans": [ + { + "range": { "start": { "line": 0, "character": 3 }, "end": { "line": 1, "character": 15 } }, + "sourceIds": ["file"] + }, + { + "range": { "start": { "line": 1, "character": 10 }, "end": { "line": 1, "character": 14 } }, + "sourceIds": ["file", "document"] + } + ], + "_meta": { "example.org/attribution": { "version": 1 } } + } + } + }, + "acceptableOutputs": [{ + "channel": "ahp-chat:/c1", + "serverSeq": 12, + "action": { + "type": "chat/responsePart", + "turnId": "turn-1", + "part": { + "kind": "attribution", + "id": "attribution-1", + "targetPartId": "answer-1", + "sources": [ + { + "id": "file", + "title": "History", + "uri": "file:///workspace/history.txt", + "contentType": "text/plain", + "excerpt": "Opened in 1984.", + "location": { + "kind": "text", + "range": { "start": { "line": 2, "character": 0 }, "end": { "line": 2, "character": 15 } } + }, + "_meta": { "example.org/revision": "v1" } + }, + { + "id": "document", + "title": "Uploaded guide", + "contentType": "application/pdf", + "location": { "kind": "page", "startPage": 4, "endPage": 5 } + } + ], + "spans": [ + { + "range": { "start": { "line": 0, "character": 3 }, "end": { "line": 1, "character": 15 } }, + "sourceIds": ["file"] + }, + { + "range": { "start": { "line": 1, "character": 10 }, "end": { "line": 1, "character": 14 } }, + "sourceIds": ["file", "document"] + } + ], + "_meta": { "example.org/attribution": { "version": 1 } } + } + } + }] +} diff --git a/types/test-cases/round-trips/046-response-attribution-snapshot.json b/types/test-cases/round-trips/046-response-attribution-snapshot.json new file mode 100644 index 00000000..2e24de84 --- /dev/null +++ b/types/test-cases/round-trips/046-response-attribution-snapshot.json @@ -0,0 +1,56 @@ +{ + "name": "response-attribution-snapshot", + "group": "A", + "description": "A chat snapshot preserves Unicode answer text, source identities, and an empty span list for whole-answer attribution.", + "type": "Snapshot", + "input": { + "resource": "ahp-chat:/c1", + "fromSeq": 14, + "state": { + "resource": "ahp-chat:/c1", + "title": "Answer", + "status": 1, + "modifiedAt": "2026-09-16T00:00:00.000Z", + "turns": [{ + "id": "turn-1", + "message": { "text": "Explain the symbol", "origin": { "kind": "user" } }, + "responseParts": [ + { "kind": "markdown", "id": "answer-1", "content": "\ud83d\ude00 means happy.\r\n" }, + { + "kind": "attribution", + "id": "attribution-1", + "targetPartId": "answer-1", + "sources": [{ "id": "guide", "title": "Symbol guide" }], + "spans": [] + } + ], + "state": "complete" + }] + } + }, + "acceptableOutputs": [{ + "resource": "ahp-chat:/c1", + "fromSeq": 14, + "state": { + "resource": "ahp-chat:/c1", + "title": "Answer", + "status": 1, + "modifiedAt": "2026-09-16T00:00:00.000Z", + "turns": [{ + "id": "turn-1", + "message": { "text": "Explain the symbol", "origin": { "kind": "user" } }, + "responseParts": [ + { "kind": "markdown", "id": "answer-1", "content": "\ud83d\ude00 means happy.\r\n" }, + { + "kind": "attribution", + "id": "attribution-1", + "targetPartId": "answer-1", + "sources": [{ "id": "guide", "title": "Symbol guide" }], + "spans": [] + } + ], + "state": "complete" + }] + } + }] +} diff --git a/types/test-cases/round-trips/047-attribution-unknown-source-location.json b/types/test-cases/round-trips/047-attribution-unknown-source-location.json new file mode 100644 index 00000000..c119f3a4 --- /dev/null +++ b/types/test-cases/round-trips/047-attribution-unknown-source-location.json @@ -0,0 +1,38 @@ +{ + "name": "attribution-unknown-source-location", + "group": "A", + "description": "An unknown source-location kind is preserved without losing the rest of the attribution.", + "type": "StateAction", + "input": { + "type": "chat/responsePart", + "turnId": "turn-1", + "part": { + "kind": "attribution", + "id": "attribution-1", + "targetPartId": "answer-1", + "sources": [{ + "id": "recording", + "title": "Recorded lecture", + "uri": "https://example.org/lecture", + "location": { "kind": "futureTimeRange", "startSeconds": 10, "endSeconds": 15 } + }], + "spans": [] + } + }, + "acceptableOutputs": [{ + "type": "chat/responsePart", + "turnId": "turn-1", + "part": { + "kind": "attribution", + "id": "attribution-1", + "targetPartId": "answer-1", + "sources": [{ + "id": "recording", + "title": "Recorded lecture", + "uri": "https://example.org/lecture", + "location": { "kind": "futureTimeRange", "startSeconds": 10, "endSeconds": 15 } + }], + "spans": [] + } + }] +}