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..46af605f
--- /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 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 4d63a2a4..e84adfe2 100644
--- a/docs/specification/chat-channel.md
+++ b/docs/specification/chat-channel.md
@@ -50,6 +50,16 @@ 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 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
[`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"
+ }
+}
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"
+ }
+}