Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1918,7 +1918,8 @@ public sealed record ChatUsageAction
/// <summary>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.</summary>
/// reasoning part, then use this action to append text or record its timing.
/// An empty `content` updates only the supplied timing fields.</summary>
public sealed record ChatReasoningAction
{
public ActionType Type { get; init; } = ActionType.ChatReasoning;
Expand All @@ -1932,6 +1933,14 @@ public sealed record ChatReasoningAction
/// <summary>Reasoning text chunk</summary>
public required string Content { get; init; }

/// <summary>Producer-recorded ISO 8601 start of the reasoning interval; omitted to preserve the existing value.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? StartedAt { get; init; }

/// <summary>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.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? Duration { get; init; }

/// <summary>Additional provider-specific metadata for this action.
///
/// Clients MAY look for well-known keys here to provide enhanced UI, and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4067,6 +4067,14 @@ public sealed class ReasoningResponsePart

/// <summary>Accumulated reasoning text</summary>
public required string Content { get; set; }

/// <summary>Producer-recorded ISO 8601 start of this reasoning interval; absent when unknown.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? StartedAt { get; set; }

/// <summary>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.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? Duration { get; set; }
}

/// <summary>A system notification surfaced as part of the response stream.
Expand Down
2 changes: 2 additions & 0 deletions clients/dotnet/src/AgentHostProtocol/Reducers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions clients/go/ahp/reducers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion clients/go/ahptypes/actions.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions clients/go/ahptypes/state.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion clients/rust/crates/ahp-types/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<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.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration: Option<i64>,
/// Additional provider-specific metadata for this action.
///
/// Clients MAY look for well-known keys here to provide enhanced UI, and
Expand Down
6 changes: 6 additions & 0 deletions clients/rust/crates/ahp-types/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration: Option<i64>,
}

/// A system notification surfaced as part of the response stream.
Expand Down
6 changes: 6 additions & 0 deletions clients/rust/crates/ahp/src/reducers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -1382,6 +1386,8 @@ public struct ChatReasoningAction: Codable, Sendable {
case turnId
case partId
case content
case startedAt
case duration
case meta = "_meta"
}

Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
4 changes: 4 additions & 0 deletions docs/.changes/20261028-reasoning-timing.json
Original file line number Diff line number Diff line change
@@ -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."
}
10 changes: 10 additions & 0 deletions docs/specification/chat-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
18 changes: 17 additions & 1 deletion schema/actions.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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": {},
Expand Down Expand Up @@ -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": [
Expand Down
18 changes: 17 additions & 1 deletion schema/commands.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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"
Expand All @@ -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": {},
Expand Down
Loading
Loading