From f9d401d792e933bca592847281d90e23d43c7a2a Mon Sep 17 00:00:00 2001 From: Justin Chen Date: Thu, 29 Oct 2026 20:56:14 -0700 Subject: [PATCH 1/2] chat: add optional reasoning timing metadata Carry producer-recorded reasoning start times and durations through protocol state, timing-only actions, and all client reducers. Preserve unknown timing and recorded zeroes across snapshots and completed turns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generated/Actions.generated.cs | 11 ++++- .../Generated/State.generated.cs | 8 ++++ .../dotnet/src/AgentHostProtocol/Reducers.cs | 2 + clients/go/ahp/reducers.go | 6 +++ clients/go/ahptypes/actions.generated.go | 7 +++- clients/go/ahptypes/state.generated.go | 4 ++ .../microsoft/agenthostprotocol/Reducers.kt | 6 ++- .../generated/Actions.generated.kt | 8 ++++ .../generated/State.generated.kt | 10 ++++- clients/rust/crates/ahp-types/src/actions.rs | 9 ++++- clients/rust/crates/ahp-types/src/state.rs | 6 +++ clients/rust/crates/ahp/src/reducers.rs | 6 +++ .../Generated/Actions.generated.swift | 10 +++++ .../Generated/State.generated.swift | 10 ++++- .../Sources/AgentHostProtocol/Reducers.swift | 2 + docs/.changes/20261028-reasoning-timing.json | 4 ++ docs/specification/chat-channel.md | 6 +++ schema/actions.schema.json | 18 ++++++++- schema/commands.schema.json | 18 ++++++++- schema/errors.schema.json | 18 ++++++++- schema/notifications.schema.json | 8 ++++ schema/state.schema.json | 8 ++++ types/channels-chat/actions.ts | 7 +++- types/channels-chat/reducer.ts | 7 +++- types/channels-chat/state.ts | 4 ++ ...oning-timing-survives-turn-completion.json | 40 +++++++++++++++++++ .../325-reasoning-timing-preserves-zero.json | 36 +++++++++++++++++ 27 files changed, 268 insertions(+), 11 deletions(-) create mode 100644 docs/.changes/20261028-reasoning-timing.json create mode 100644 types/test-cases/reducers/324-reasoning-timing-survives-turn-completion.json create mode 100644 types/test-cases/reducers/325-reasoning-timing-preserves-zero.json diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index cc271906..0f580d67 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -1918,7 +1918,8 @@ public sealed record ChatUsageAction /// Reasoning/thinking text from the model, appended to a specific reasoning response part. /// /// The server MUST first emit a `chat/responsePart` to create the target -/// reasoning part, then use this action to append text to it. +/// reasoning part, then use this action to append text or record its timing. +/// An empty `content` updates only the supplied timing fields. public sealed record ChatReasoningAction { public ActionType Type { get; init; } = ActionType.ChatReasoning; @@ -1932,6 +1933,14 @@ public sealed record ChatReasoningAction /// Reasoning text chunk public required string Content { get; init; } + /// Producer-recorded ISO 8601 start of the reasoning interval; omitted to preserve the existing value. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? StartedAt { get; init; } + + /// Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; omitted to preserve the existing value. Zero is a recorded duration, not unknown. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Duration { get; init; } + /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index 8d42cbef..a9608b18 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -4067,6 +4067,14 @@ public sealed class ReasoningResponsePart /// Accumulated reasoning text public required string Content { get; set; } + + /// Producer-recorded ISO 8601 start of this reasoning interval; absent when unknown. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? StartedAt { get; set; } + + /// Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; absent while unfinished or unknown. Clients MUST NOT derive it from their own clock. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Duration { get; set; } } /// A system notification surfaced as part of the response stream. diff --git a/clients/dotnet/src/AgentHostProtocol/Reducers.cs b/clients/dotnet/src/AgentHostProtocol/Reducers.cs index d7ca58b8..5a7f8968 100644 --- a/clients/dotnet/src/AgentHostProtocol/Reducers.cs +++ b/clients/dotnet/src/AgentHostProtocol/Reducers.cs @@ -1112,6 +1112,8 @@ public static ReduceOutcome ApplyToChat(ChatState state, StateAction action) if (p.Value is ReasoningResponsePart r) { r.Content += a.Content; + r.StartedAt = a.StartedAt ?? r.StartedAt; + r.Duration = a.Duration ?? r.Duration; } }); case ChatTruncatedAction a: diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 36e5f379..b83e249a 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -717,6 +717,12 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R return updateResponsePart(state, a.TurnId, a.PartId, func(p *ahptypes.ResponsePart) { if r, ok := p.Value.(*ahptypes.ReasoningResponsePart); ok { r.Content += a.Content + if a.StartedAt != nil { + r.StartedAt = a.StartedAt + } + if a.Duration != nil { + r.Duration = a.Duration + } } }) case *ahptypes.ChatTruncatedAction: diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index 49182b74..9412b66d 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -762,7 +762,8 @@ type ChatUsageAction struct { // Reasoning/thinking text from the model, appended to a specific reasoning response part. // // The server MUST first emit a `chat/responsePart` to create the target -// reasoning part, then use this action to append text to it. +// reasoning part, then use this action to append text or record its timing. +// An empty `content` updates only the supplied timing fields. type ChatReasoningAction struct { Type ActionType `json:"type"` // Turn identifier @@ -771,6 +772,10 @@ type ChatReasoningAction struct { PartId string `json:"partId"` // Reasoning text chunk Content string `json:"content"` + // Producer-recorded ISO 8601 start of the reasoning interval; omitted to preserve the existing value. + StartedAt *string `json:"startedAt,omitempty"` + // Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; omitted to preserve the existing value. Zero is a recorded duration, not unknown. + Duration *int64 `json:"duration,omitempty"` // Additional provider-specific metadata for this action. // // Clients MAY look for well-known keys here to provide enhanced UI, and diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 17770726..f1bbd1af 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -2278,6 +2278,10 @@ type ReasoningResponsePart struct { Id string `json:"id"` // Accumulated reasoning text Content string `json:"content"` + // Producer-recorded ISO 8601 start of this reasoning interval; absent when unknown. + StartedAt *string `json:"startedAt,omitempty"` + // Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; absent while unfinished or unknown. Clients MUST NOT derive it from their own clock. + Duration *int64 `json:"duration,omitempty"` } // A system notification surfaced as part of the response stream. 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 7d97b659..d25caae3 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -1468,7 +1468,11 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when val a = action.value updateResponsePart(state, a.turnId, a.partId) { part -> if (part is ResponsePartReasoning) { - ResponsePartReasoning(part.value.copy(content = part.value.content + a.content)) + ResponsePartReasoning(part.value.copy( + content = part.value.content + a.content, + startedAt = a.startedAt ?: part.value.startedAt, + duration = a.duration ?: part.value.duration, + )) } else { part } diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt index 10a509cc..23797a19 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt @@ -850,6 +850,14 @@ data class ChatReasoningAction( * Reasoning text chunk */ val content: String, + /** + * Producer-recorded ISO 8601 start of the reasoning interval; omitted to preserve the existing value. + */ + val startedAt: String? = null, + /** + * Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; omitted to preserve the existing value. Zero is a recorded duration, not unknown. + */ + val duration: Long? = null, /** * Additional provider-specific metadata for this action. * 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 f962da07..23f08018 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 @@ -3350,7 +3350,15 @@ data class ReasoningResponsePart( /** * Accumulated reasoning text */ - val content: String + val content: String, + /** + * Producer-recorded ISO 8601 start of this reasoning interval; absent when unknown. + */ + val startedAt: String? = null, + /** + * Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; absent while unfinished or unknown. Clients MUST NOT derive it from their own clock. + */ + val duration: Long? = null ) @Serializable diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 6b005fcf..54641be7 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -1208,7 +1208,8 @@ pub struct ChatUsageAction { /// Reasoning/thinking text from the model, appended to a specific reasoning response part. /// /// The server MUST first emit a `chat/responsePart` to create the target -/// reasoning part, then use this action to append text to it. +/// reasoning part, then use this action to append text or record its timing. +/// An empty `content` updates only the supplied timing fields. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatReasoningAction { @@ -1218,6 +1219,12 @@ pub struct ChatReasoningAction { pub part_id: String, /// Reasoning text chunk pub content: String, + /// Producer-recorded ISO 8601 start of the reasoning interval; omitted to preserve the existing value. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option, + /// Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; omitted to preserve the existing value. Zero is a recorded duration, not unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration: Option, /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index b7e5d6df..76e3744e 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -3562,6 +3562,12 @@ pub struct ReasoningResponsePart { pub id: String, /// Accumulated reasoning text pub content: String, + /// Producer-recorded ISO 8601 start of this reasoning interval; absent when unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option, + /// Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; absent while unfinished or unknown. Clients MUST NOT derive it from their own clock. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration: Option, } /// A system notification surfaced as part of the response stream. diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index b201ef8e..d8e7de17 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -1292,6 +1292,12 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu StateAction::ChatReasoning(a) => update_response_part(state, &a.turn_id, &a.part_id, |p| { if let ResponsePart::Reasoning(r) = p { r.content.push_str(&a.content); + if let Some(started_at) = &a.started_at { + r.started_at = Some(started_at.clone()); + } + if let Some(duration) = a.duration { + r.duration = Some(duration); + } } }), StateAction::ChatTruncated(a) => apply_truncated(state, a.turn_id.as_deref()), diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 513db092..707d69c2 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -1368,6 +1368,10 @@ public struct ChatReasoningAction: Codable, Sendable { public var partId: String /// Reasoning text chunk public var content: String + /// Producer-recorded ISO 8601 start of the reasoning interval; omitted to preserve the existing value. + public var startedAt: String? + /// Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; omitted to preserve the existing value. Zero is a recorded duration, not unknown. + public var duration: Int? /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -1382,6 +1386,8 @@ public struct ChatReasoningAction: Codable, Sendable { case turnId case partId case content + case startedAt + case duration case meta = "_meta" } @@ -1390,12 +1396,16 @@ public struct ChatReasoningAction: Codable, Sendable { turnId: String, partId: String, content: String, + startedAt: String? = nil, + duration: Int? = nil, meta: [String: AnyCodable]? = nil ) { self.type = type self.turnId = turnId self.partId = partId self.content = content + self.startedAt = startedAt + self.duration = duration self.meta = meta } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index ccfee6fe..93899322 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -3648,15 +3648,23 @@ public struct ReasoningResponsePart: Codable, Sendable { public var id: String /// Accumulated reasoning text public var content: String + /// Producer-recorded ISO 8601 start of this reasoning interval; absent when unknown. + public var startedAt: String? + /// Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; absent while unfinished or unknown. Clients MUST NOT derive it from their own clock. + public var duration: Int? public init( kind: ResponsePartKind, id: String, - content: String + content: String, + startedAt: String? = nil, + duration: Int? = nil ) { self.kind = kind self.id = id self.content = content + self.startedAt = startedAt + self.duration = duration } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index b2831e3e..502d8fc9 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -586,6 +586,8 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { return updateResponsePart(state: state, turnId: a.turnId, partId: a.partId) { part in guard case .reasoning(var r) = part else { return part } r.content += a.content + r.startedAt = a.startedAt ?? r.startedAt + r.duration = a.duration ?? r.duration return .reasoning(r) } diff --git a/docs/.changes/20261028-reasoning-timing.json b/docs/.changes/20261028-reasoning-timing.json new file mode 100644 index 00000000..88132c8f --- /dev/null +++ b/docs/.changes/20261028-reasoning-timing.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "Optional producer-recorded `startedAt` and `duration` fields on reasoning response parts and `chat/reasoning` updates preserve reasoning timing across state snapshots and history restoration." +} diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 4d63a2a4..ded5581d 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -50,6 +50,12 @@ operation that references a turn outside the currently loaded window (for example, a fork, side-chat source, chat attachment, or truncation targeting an older turn). +### Reasoning timing + +[`ReasoningResponsePart`](/reference/chat#reasoningresponsepart) may include a producer-recorded `startedAt` timestamp and a final `duration` in non-negative integer milliseconds. Timing is optional: an absent duration means unfinished or unknown, not zero. Clients MUST treat a supplied duration as producer-owned data rather than measuring replay, subtracting their local clock from `startedAt`, or substituting a minimum display value into stored state. + +The host can publish timing with the initial `chat/responsePart` or update it through `chat/reasoning`. A timing-only update uses an empty `content`; omitted timing fields preserve their previous values. The host MUST send final timing before the turn's terminal action and SHOULD retain the same timing when restoring history, so subscriptions, reconnects and reloads show consistent values. Unknown historical or interrupted intervals remain untimed unless the producer has an authoritative measurement; time while a host is stopped is not reasoning time. + ### Drafts [`ChatState.draft`](/reference/chat#chatstate) is the user's in-progress input for a chat — the [`Message`](/reference/chat#message) they are composing but have not sent yet, including its model/agent selection and attachments. Unlike the fields above, `draft` is state-only and is **not** mirrored onto [`ChatSummary`](/reference/chat#chatsummary). diff --git a/schema/actions.schema.json b/schema/actions.schema.json index fae03cb5..ca2a733f 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -1523,7 +1523,7 @@ }, "ChatReasoningAction": { "type": "object", - "description": "Reasoning/thinking text from the model, appended to a specific reasoning response part.\n\nThe server MUST first emit a `chat/responsePart` to create the target\nreasoning part, then use this action to append text to it.", + "description": "Reasoning/thinking text from the model, appended to a specific reasoning response part.\n\nThe server MUST first emit a `chat/responsePart` to create the target\nreasoning part, then use this action to append text or record its timing.\nAn empty `content` updates only the supplied timing fields.", "properties": { "type": { "const": "chat/reasoning" @@ -1540,6 +1540,14 @@ "type": "string", "description": "Reasoning text chunk" }, + "startedAt": { + "type": "string", + "description": "Producer-recorded ISO 8601 start of the reasoning interval; omitted to preserve the existing value." + }, + "duration": { + "type": "number", + "description": "Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; omitted to preserve the existing value. Zero is a recorded duration, not unknown." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -6456,6 +6464,14 @@ "content": { "type": "string", "description": "Accumulated reasoning text" + }, + "startedAt": { + "type": "string", + "description": "Producer-recorded ISO 8601 start of this reasoning interval; absent when unknown." + }, + "duration": { + "type": "number", + "description": "Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; absent while unfinished or unknown. Clients MUST NOT derive it from their own clock." } }, "required": [ diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 1452ca64..fe714a45 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -5749,6 +5749,14 @@ "content": { "type": "string", "description": "Accumulated reasoning text" + }, + "startedAt": { + "type": "string", + "description": "Producer-recorded ISO 8601 start of this reasoning interval; absent when unknown." + }, + "duration": { + "type": "number", + "description": "Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; absent while unfinished or unknown. Clients MUST NOT derive it from their own clock." } }, "required": [ @@ -9559,7 +9567,7 @@ }, "ChatReasoningAction": { "type": "object", - "description": "Reasoning/thinking text from the model, appended to a specific reasoning response part.\n\nThe server MUST first emit a `chat/responsePart` to create the target\nreasoning part, then use this action to append text to it.", + "description": "Reasoning/thinking text from the model, appended to a specific reasoning response part.\n\nThe server MUST first emit a `chat/responsePart` to create the target\nreasoning part, then use this action to append text or record its timing.\nAn empty `content` updates only the supplied timing fields.", "properties": { "type": { "const": "chat/reasoning" @@ -9576,6 +9584,14 @@ "type": "string", "description": "Reasoning text chunk" }, + "startedAt": { + "type": "string", + "description": "Producer-recorded ISO 8601 start of the reasoning interval; omitted to preserve the existing value." + }, + "duration": { + "type": "number", + "description": "Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; omitted to preserve the existing value. Zero is a recorded duration, not unknown." + }, "_meta": { "type": "object", "additionalProperties": {}, diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 4130e292..efe19314 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -3838,6 +3838,14 @@ "content": { "type": "string", "description": "Accumulated reasoning text" + }, + "startedAt": { + "type": "string", + "description": "Producer-recorded ISO 8601 start of this reasoning interval; absent when unknown." + }, + "duration": { + "type": "number", + "description": "Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; absent while unfinished or unknown. Clients MUST NOT derive it from their own clock." } }, "required": [ @@ -10658,7 +10666,7 @@ }, "ChatReasoningAction": { "type": "object", - "description": "Reasoning/thinking text from the model, appended to a specific reasoning response part.\n\nThe server MUST first emit a `chat/responsePart` to create the target\nreasoning part, then use this action to append text to it.", + "description": "Reasoning/thinking text from the model, appended to a specific reasoning response part.\n\nThe server MUST first emit a `chat/responsePart` to create the target\nreasoning part, then use this action to append text or record its timing.\nAn empty `content` updates only the supplied timing fields.", "properties": { "type": { "const": "chat/reasoning" @@ -10675,6 +10683,14 @@ "type": "string", "description": "Reasoning text chunk" }, + "startedAt": { + "type": "string", + "description": "Producer-recorded ISO 8601 start of the reasoning interval; omitted to preserve the existing value." + }, + "duration": { + "type": "number", + "description": "Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; omitted to preserve the existing value. Zero is a recorded duration, not unknown." + }, "_meta": { "type": "object", "additionalProperties": {}, diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index e7c52ae9..be9920cb 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -4140,6 +4140,14 @@ "content": { "type": "string", "description": "Accumulated reasoning text" + }, + "startedAt": { + "type": "string", + "description": "Producer-recorded ISO 8601 start of this reasoning interval; absent when unknown." + }, + "duration": { + "type": "number", + "description": "Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; absent while unfinished or unknown. Clients MUST NOT derive it from their own clock." } }, "required": [ diff --git a/schema/state.schema.json b/schema/state.schema.json index cf0b5253..5c2f683c 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -3749,6 +3749,14 @@ "content": { "type": "string", "description": "Accumulated reasoning text" + }, + "startedAt": { + "type": "string", + "description": "Producer-recorded ISO 8601 start of this reasoning interval; absent when unknown." + }, + "duration": { + "type": "number", + "description": "Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; absent while unfinished or unknown. Clients MUST NOT derive it from their own clock." } }, "required": [ diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index ca1fb415..e6cdc31f 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -696,7 +696,8 @@ export interface ChatUsageAction { * Reasoning/thinking text from the model, appended to a specific reasoning response part. * * The server MUST first emit a `chat/responsePart` to create the target - * reasoning part, then use this action to append text to it. + * reasoning part, then use this action to append text or record its timing. + * An empty `content` updates only the supplied timing fields. * * @category Chat Actions * @version 1 @@ -709,6 +710,10 @@ export interface ChatReasoningAction { partId: string; /** Reasoning text chunk */ content: string; + /** Producer-recorded ISO 8601 start of the reasoning interval; omitted to preserve the existing value. */ + startedAt?: string; + /** Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; omitted to preserve the existing value. Zero is a recorded duration, not unknown. */ + duration?: number; /** * Additional provider-specific metadata for this action. * diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index d722df6c..6da04f39 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -782,7 +782,12 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st case ActionType.ChatReasoning: return updateResponsePart(state, action.turnId, action.partId, part => { if (part.kind === ResponsePartKind.Reasoning) { - return { ...part, content: part.content + action.content }; + return { + ...part, + content: part.content + action.content, + ...(action.startedAt !== undefined ? { startedAt: action.startedAt } : {}), + ...(action.duration !== undefined ? { duration: action.duration } : {}), + }; } return part; }); diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index 9e99a379..eb3cf0cb 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -1148,6 +1148,10 @@ export interface ReasoningResponsePart { id: string; /** Accumulated reasoning text */ content: string; + /** Producer-recorded ISO 8601 start of this reasoning interval; absent when unknown. */ + startedAt?: string; + /** Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; absent while unfinished or unknown. Clients MUST NOT derive it from their own clock. */ + duration?: number; } /** diff --git a/types/test-cases/reducers/324-reasoning-timing-survives-turn-completion.json b/types/test-cases/reducers/324-reasoning-timing-survives-turn-completion.json new file mode 100644 index 00000000..fb59b283 --- /dev/null +++ b/types/test-cases/reducers/324-reasoning-timing-survives-turn-completion.json @@ -0,0 +1,40 @@ +{ + "description": "reasoning timing-only updates preserve content and survive turn completion", + "reducer": "chat", + "initial": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { "text": "Hello", "origin": { "kind": "user" } }, + "responseParts": [{ "kind": "reasoning", "id": "r-1", "content": "Thinking" }], + "usage": null + }, + "resource": "custom-host://opaque/session/chat", + "title": "Test Chat", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { "type": "chat/reasoning", "turnId": "turn-1", "partId": "r-1", "content": "", "startedAt": "1970-01-01T00:00:01.500Z" }, + { "type": "chat/reasoning", "turnId": "turn-1", "partId": "r-1", "content": " about the answer" }, + { "type": "chat/reasoning", "turnId": "turn-1", "partId": "r-1", "content": "", "duration": 1250 }, + { "type": "chat/turnComplete", "turnId": "turn-1", "duration": 3000 } + ], + "expected": { + "activeTurn": null, + "turns": [{ + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 3000, + "message": { "text": "Hello", "origin": { "kind": "user" } }, + "responseParts": [{ "kind": "reasoning", "id": "r-1", "content": "Thinking about the answer", "startedAt": "1970-01-01T00:00:01.500Z", "duration": 1250 }], + "usage": null, + "state": "complete" + }], + "resource": "custom-host://opaque/session/chat", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:04.000Z" + } +} diff --git a/types/test-cases/reducers/325-reasoning-timing-preserves-zero.json b/types/test-cases/reducers/325-reasoning-timing-preserves-zero.json new file mode 100644 index 00000000..925d42bd --- /dev/null +++ b/types/test-cases/reducers/325-reasoning-timing-preserves-zero.json @@ -0,0 +1,36 @@ +{ + "description": "reasoning timing updates accept zero duration and replace supplied fields only", + "reducer": "chat", + "initial": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { "text": "Hello", "origin": { "kind": "user" } }, + "responseParts": [{ "kind": "reasoning", "id": "r-1", "content": "Thinking", "startedAt": "1970-01-01T00:00:01.000Z", "duration": 500 }], + "usage": null + }, + "resource": "custom-host://opaque/session/chat", + "title": "Test Chat", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { "type": "chat/reasoning", "turnId": "turn-1", "partId": "r-1", "content": "", "startedAt": "1970-01-01T00:00:01.500Z", "duration": 0 }, + { "type": "chat/reasoning", "turnId": "turn-1", "partId": "r-1", "content": "" } + ], + "expected": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { "text": "Hello", "origin": { "kind": "user" } }, + "responseParts": [{ "kind": "reasoning", "id": "r-1", "content": "Thinking", "startedAt": "1970-01-01T00:00:01.500Z", "duration": 0 }], + "usage": null + }, + "resource": "custom-host://opaque/session/chat", + "title": "Test Chat", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} From f0cf2adc12486e4668e04fc0f070d1ef7d51a56e Mon Sep 17 00:00:00 2001 From: Justin Chen Date: Thu, 29 Oct 2026 22:09:19 -0700 Subject: [PATCH 2/2] chat: define contiguous reasoning timing boundaries Define thinking runs independently of SDK block and message boundaries. Preserve their identities and individual durations around markdown and tool output, with shared 5s/12s conformance coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/.changes/20261028-reasoning-timing.json | 2 +- docs/specification/chat-channel.md | 4 ++ ...easoning-runs-keep-separate-durations.json | 46 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 types/test-cases/reducers/326-contiguous-reasoning-runs-keep-separate-durations.json diff --git a/docs/.changes/20261028-reasoning-timing.json b/docs/.changes/20261028-reasoning-timing.json index 88132c8f..46af605f 100644 --- a/docs/.changes/20261028-reasoning-timing.json +++ b/docs/.changes/20261028-reasoning-timing.json @@ -1,4 +1,4 @@ { "type": "added", - "message": "Optional producer-recorded `startedAt` and `duration` fields on reasoning response parts and `chat/reasoning` updates preserve reasoning timing across state snapshots and history restoration." + "message": "Optional producer-recorded `startedAt` and `duration` fields on reasoning response parts and `chat/reasoning` updates preserve each contiguous reasoning run's timing across state snapshots and history restoration." } diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index ded5581d..e84adfe2 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -54,6 +54,10 @@ older turn). [`ReasoningResponsePart`](/reference/chat#reasoningresponsepart) may include a producer-recorded `startedAt` timestamp and a final `duration` in non-negative integer milliseconds. Timing is optional: an absent duration means unfinished or unknown, not zero. Clients MUST treat a supplied duration as producer-owned data rather than measuring replay, subtracting their local clock from `startedAt`, or substituting a minimum display value into stored state. +The measured unit is one contiguous run of reasoning output, not an SDK block, assistant message, or UI group. It starts at the first nonempty reasoning text and ends when the next markdown or tool-call part begins, or when the turn terminates. Consecutive reasoning pieces belong to the same part even when the provider changes its internal reasoning ID or completes an SDK message. Sideband notifications and metadata do not end the run. + +For example, `tool -> reasoning (5s) -> markdown -> reasoning (12s) -> tool` has two reasoning parts with durations `5000` and `12000`, not one duration of `17000`. Hosts SHOULD preserve the run identities and intervening content when restoring history, even if the provider's transcript stores an aggregated message. Clients that group progress should retain access to each run's duration rather than substituting a group total. + The host can publish timing with the initial `chat/responsePart` or update it through `chat/reasoning`. A timing-only update uses an empty `content`; omitted timing fields preserve their previous values. The host MUST send final timing before the turn's terminal action and SHOULD retain the same timing when restoring history, so subscriptions, reconnects and reloads show consistent values. Unknown historical or interrupted intervals remain untimed unless the producer has an authoritative measurement; time while a host is stopped is not reasoning time. ### Drafts diff --git a/types/test-cases/reducers/326-contiguous-reasoning-runs-keep-separate-durations.json b/types/test-cases/reducers/326-contiguous-reasoning-runs-keep-separate-durations.json new file mode 100644 index 00000000..309a75a3 --- /dev/null +++ b/types/test-cases/reducers/326-contiguous-reasoning-runs-keep-separate-durations.json @@ -0,0 +1,46 @@ +{ + "description": "contiguous reasoning runs separated by markdown retain independent durations", + "reducer": "chat", + "initial": { + "turns": [], + "activeTurn": { + "id": "turn", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { "text": "Hello", "origin": { "kind": "user" } }, + "responseParts": [], + "usage": null + }, + "resource": "custom-host://opaque/chat", + "title": "Test Chat", + "status": 8, + "modifiedAt": "1970-01-01T00:00:01.000Z" + }, + "actions": [ + { "type": "chat/responsePart", "turnId": "turn", "part": { "kind": "reasoning", "id": "first", "content": "First", "startedAt": "1970-01-01T00:00:01.000Z" } }, + { "type": "chat/reasoning", "turnId": "turn", "partId": "first", "content": "", "duration": 5000 }, + { "type": "chat/responsePart", "turnId": "turn", "part": { "kind": "markdown", "id": "between", "content": "Between" } }, + { "type": "chat/responsePart", "turnId": "turn", "part": { "kind": "reasoning", "id": "second", "content": "Second", "startedAt": "1970-01-01T00:00:09.000Z" } }, + { "type": "chat/reasoning", "turnId": "turn", "partId": "second", "content": "", "duration": 12000 }, + { "type": "chat/turnComplete", "turnId": "turn", "duration": 20000 } + ], + "expected": { + "activeTurn": null, + "turns": [{ + "id": "turn", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 20000, + "message": { "text": "Hello", "origin": { "kind": "user" } }, + "responseParts": [ + { "kind": "reasoning", "id": "first", "content": "First", "startedAt": "1970-01-01T00:00:01.000Z", "duration": 5000 }, + { "kind": "markdown", "id": "between", "content": "Between" }, + { "kind": "reasoning", "id": "second", "content": "Second", "startedAt": "1970-01-01T00:00:09.000Z", "duration": 12000 } + ], + "usage": null, + "state": "complete" + }], + "resource": "custom-host://opaque/chat", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:21.000Z" + } +}