From 6e97acdeef811c2cd9b7df02e00058dfd9409a53 Mon Sep 17 00:00:00 2001 From: justschen Date: Thu, 9 Jul 2026 21:30:17 -0700 Subject: [PATCH 1/3] chat: add turn timing metadata - Carries authoritative start and end timestamps through turn lifecycle actions so clients can render consistent timing information. - Persists turn start times and derived durations in state while preserving compatibility with older completed turns. - Keeps reducer behavior aligned across TypeScript, Rust, Kotlin, Swift, and Go, including invalid timestamp handling. - Regenerates schemas and client wire types and extends shared conformance fixtures. References microsoft/vscode#325061 (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahp/reducers.go | 31 ++++++-- clients/go/ahptypes/actions.generated.go | 8 +++ clients/go/ahptypes/state.generated.go | 6 ++ clients/go/examples/reducers_demo/main.go | 12 ++-- .../microsoft/agenthostprotocol/Reducers.kt | 70 ++++++++++++------- .../generated/Actions.generated.kt | 16 +++++ .../generated/State.generated.kt | 12 ++++ .../agenthostprotocol/ReducersTest.kt | 8 +-- clients/rust/Cargo.lock | 25 +++++++ clients/rust/Cargo.toml | 1 + clients/rust/crates/ahp-types/src/actions.rs | 8 +++ clients/rust/crates/ahp-types/src/state.rs | 8 +++ clients/rust/crates/ahp/Cargo.toml | 1 + clients/rust/crates/ahp/src/reducers.rs | 53 +++++++++++--- .../Generated/Actions.generated.swift | 20 ++++++ .../Generated/State.generated.swift | 12 ++++ .../Sources/AgentHostProtocol/Reducers.swift | 34 +++++++-- .../NativeReducerTests.swift | 1 + .../ReducersTests.swift | 9 ++- docs/.changes/20260709-turn-timing.json | 4 ++ schema/actions.schema.json | 37 +++++++++- schema/commands.schema.json | 37 +++++++++- schema/errors.schema.json | 37 +++++++++- schema/notifications.schema.json | 13 ++++ schema/state.schema.json | 13 ++++ types/channels-chat/actions.ts | 8 +++ types/channels-chat/reducer.ts | 65 +++++++++++++++-- types/channels-chat/state.ts | 6 ++ types/reducers.test.ts | 2 +- .../reducers/005-session-turnstarted.json | 4 +- ...messageid-removes-from-queuedmessages.json | 4 +- ...messageid-removes-last-queued-message.json | 4 +- ...eid-removes-matching-steering-message.json | 4 +- ...ageid-does-not-touch-pending-messages.json | 4 +- .../010-session-delta-appends-content.json | 2 + ...sion-delta-with-wrong-turnid-is-no-op.json | 2 + ...on-responsepart-adds-to-responseparts.json | 2 + ...4-session-turncomplete-finalizes-turn.json | 6 +- ...-session-turncancelled-finalizes-turn.json | 6 +- ...ssion-error-finalizes-turn-with-error.json | 4 ++ ...-force-cancels-in-progress-tool-calls.json | 6 +- ...rncomplete-with-wrong-turnid-is-no-op.json | 5 +- ...-start-delta-ready-confirmed-complete.json | 2 + ...h-auto-confirm-transitions-to-running.json | 2 + ...-call-denied-transitions-to-cancelled.json | 2 + ...-result-confirmation-pending-approved.json | 2 + ...d-cancelled-with-result-denied-reason.json | 2 + ...nding-confirmation-defaults-confirmed.json | 2 + ...ions-for-unknown-toolcallid-are-no-op.json | 2 + ...ing-tool-back-to-pending-confirmation.json | 2 + ...-approved-transitions-back-to-running.json | 2 + ...ation-denied-transitions-to-cancelled.json | 2 + ...-non-streaming-non-running-tool-calls.json | 2 + ...on-usage-updates-usage-on-active-turn.json | 2 + ...n-reasoning-appends-reasoning-content.json | 2 + ...7-session-truncated-drops-active-turn.json | 1 + ...ps-active-turn-even-when-clearing-all.json | 1 + ...w-with-tool-calls-and-re-confirmation.json | 6 +- .../075-turnstarted-clears-isread.json | 4 +- ...olcall-contentchanged-updates-running.json | 2 + ...lcall-contentchanged-noop-non-running.json | 2 + ...call-contentchanged-replaces-existing.json | 2 + ...0-toolcalldelta-wrong-turnid-is-no-op.json | 2 + ...91-responsepart-wrong-turnid-is-no-op.json | 2 + ...2-toolcallstart-wrong-turnid-is-no-op.json | 2 + .../093-usage-wrong-turnid-is-no-op.json | 2 + ...094-delta-nonexistent-partid-is-no-op.json | 2 + ...5-toolcalldelta-wrong-status-is-no-op.json | 2 + ...olcallconfirmed-wrong-status-is-no-op.json | 2 + ...oolcallcomplete-wrong-status-is-no-op.json | 2 + ...resultconfirmed-wrong-status-is-no-op.json | 2 + ...dturn-force-cancels-running-tool-call.json | 6 +- ...ta-targeting-toolcall-partid-is-no-op.json | 2 + ...olcalldelta-without-invocationmessage.json | 2 + ...ning-targeting-non-reasoning-is-no-op.json | 2 + .../103-delta-skips-parts-without-id.json | 2 + ...on-input-full-draft-and-complete-flow.json | 2 + ...on-input-requested-with-drafts-status.json | 2 + ...nput-turn-end-cleans-turn-scoped-only.json | 6 +- ...session-input-upsert-and-clear-answer.json | 2 + ...t-completion-and-truncation-filtering.json | 1 + ...confirmation-sets-input-needed-status.json | 2 + ...confirmation-sets-input-needed-status.json | 2 + ...olcallready-with-confirmation-options.json | 2 + ...onfirmed-approved-with-selectedoption.json | 2 + ...lconfirmed-denied-with-selectedoption.json | 2 + ...edoption-carries-through-to-completed.json | 2 + ...n-carries-through-result-confirmation.json | 2 + ...doption-carries-through-result-denied.json | 2 + ...th-editedtoolinput-overrides-original.json | 2 + .../161-chat-turn-lifecycle-on-chat.json | 6 +- ...ies-mcp-contributor-through-lifecycle.json | 2 + .../220-toolcall-actions-update-meta.json | 2 + ...tion-records-declined-request-in-turn.json | 2 + ...n-no-op-unknown-id-with-open-requests.json | 2 + ...invalid-turn-start-timestamp-is-no-op.json | 31 ++++++++ ...9-invalid-turn-end-timestamp-is-no-op.json | 49 +++++++++++++ .../240-turn-duration-is-clamped-to-zero.json | 54 ++++++++++++++ 98 files changed, 779 insertions(+), 86 deletions(-) create mode 100644 docs/.changes/20260709-turn-timing.json create mode 100644 types/test-cases/reducers/238-invalid-turn-start-timestamp-is-no-op.json create mode 100644 types/test-cases/reducers/239-invalid-turn-end-timestamp-is-no-op.json create mode 100644 types/test-cases/reducers/240-turn-duration-is-clamped-to-zero.json diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index e7ffacb3..c3905761 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -181,7 +181,11 @@ func touchChatModified(state *ahptypes.ChatState) { // ─── Active-turn helpers ─────────────────────────────────────────────── -func endTurn(state *ahptypes.ChatState, turnID string, turnState ahptypes.TurnState, terminalStatus *ahptypes.SessionStatus, errInfo *ahptypes.ErrorInfo) ReduceOutcome { +func endTurn(state *ahptypes.ChatState, turnID, endedAt string, turnState ahptypes.TurnState, terminalStatus *ahptypes.SessionStatus, errInfo *ahptypes.ErrorInfo) ReduceOutcome { + ended, err := time.Parse(time.RFC3339Nano, endedAt) + if err != nil { + return ReduceOutcomeNoOp + } if state.ActiveTurn == nil || state.ActiveTurn.Id != turnID { return ReduceOutcomeNoOp } @@ -220,8 +224,19 @@ func endTurn(state *ahptypes.ChatState, turnID string, turnState ahptypes.TurnSt }}) } + var duration *int64 + if started, err := time.Parse(time.RFC3339Nano, active.StartedAt); err == nil { + ms := ended.UnixMilli() - started.UnixMilli() + if ms < 0 { + ms = 0 + } + duration = &ms + } + startedAt := active.StartedAt turn := ahptypes.Turn{ Id: active.Id, + StartedAt: &startedAt, + Duration: duration, Message: active.Message, ResponseParts: parts, Usage: active.Usage, @@ -231,7 +246,7 @@ func endTurn(state *ahptypes.ChatState, turnID string, turnState ahptypes.TurnSt state.Turns = append(state.Turns, turn) state.InputRequests = nil - touchChatModified(state) + state.ModifiedAt = endedAt state.Status = summaryStatus(state, terminalStatus) return ReduceOutcomeApplied } @@ -464,13 +479,13 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R state.ActiveTurn.ResponseParts = append(state.ActiveTurn.ResponseParts, a.Part) return ReduceOutcomeApplied case *ahptypes.ChatTurnCompleteAction: - return endTurn(state, a.TurnId, ahptypes.TurnStateComplete, nil, nil) + return endTurn(state, a.TurnId, a.EndedAt, ahptypes.TurnStateComplete, nil, nil) case *ahptypes.ChatTurnCancelledAction: - return endTurn(state, a.TurnId, ahptypes.TurnStateCancelled, nil, nil) + return endTurn(state, a.TurnId, a.EndedAt, ahptypes.TurnStateCancelled, nil, nil) case *ahptypes.ChatErrorAction: errCopy := a.Error errStatus := ahptypes.SessionStatusError - return endTurn(state, a.TurnId, ahptypes.TurnStateError, &errStatus, &errCopy) + return endTurn(state, a.TurnId, a.EndedAt, ahptypes.TurnStateError, &errStatus, &errCopy) case *ahptypes.ChatActivityChangedAction: state.Activity = a.Activity return ReduceOutcomeApplied @@ -922,13 +937,17 @@ func ApplyActionToSession(state *ahptypes.SessionState, action ahptypes.StateAct } func applyTurnStarted(state *ahptypes.ChatState, a *ahptypes.ChatTurnStartedAction) ReduceOutcome { + if _, err := time.Parse(time.RFC3339Nano, a.StartedAt); err != nil { + return ReduceOutcomeNoOp + } state.ActiveTurn = &ahptypes.ActiveTurn{ Id: a.TurnId, + StartedAt: a.StartedAt, Message: a.Message, ResponseParts: []ahptypes.ResponsePart{}, } state.Status = summaryStatus(state, nil) - touchChatModified(state) + state.ModifiedAt = a.StartedAt state.Status = withStatusFlag(state.Status, ahptypes.SessionStatusIsRead, false) if a.QueuedMessageId != nil { diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index f5f61b18..ca866fb1 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -212,6 +212,8 @@ type ChatTurnStartedAction struct { Type ActionType `json:"type"` // Turn identifier TurnId string `json:"turnId"` + // ISO 8601 timestamp when this turn started. + StartedAt string `json:"startedAt"` // The new message Message Message `json:"message"` // If this turn was auto-started from a queued message, the ID of that message @@ -460,6 +462,8 @@ type ChatTurnCompleteAction struct { Type ActionType `json:"type"` // Turn identifier TurnId string `json:"turnId"` + // ISO 8601 timestamp when this turn completed. + EndedAt string `json:"endedAt"` // Additional provider-specific metadata for this action. // // Clients MAY look for well-known keys here to provide enhanced UI, and @@ -475,6 +479,8 @@ type ChatTurnCancelledAction struct { Type ActionType `json:"type"` // Turn identifier TurnId string `json:"turnId"` + // ISO 8601 timestamp when this turn was cancelled. + EndedAt string `json:"endedAt"` // Additional provider-specific metadata for this action. // // Clients MAY look for well-known keys here to provide enhanced UI, and @@ -490,6 +496,8 @@ type ChatErrorAction struct { Type ActionType `json:"type"` // Turn identifier TurnId string `json:"turnId"` + // ISO 8601 timestamp when this turn failed. + EndedAt string `json:"endedAt"` // Error details Error ErrorInfo `json:"error"` // Additional provider-specific metadata for this action. diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 09d345d4..6e57fc01 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -1148,6 +1148,10 @@ type SessionConfigState struct { type Turn struct { // Turn identifier Id string `json:"id"` + // ISO 8601 timestamp when this turn started. + StartedAt *string `json:"startedAt,omitempty"` + // Turn duration in milliseconds. + Duration *int64 `json:"duration,omitempty"` // The message that initiated the turn Message Message `json:"message"` // All response content in stream order: text, tool calls, reasoning, and content refs. @@ -1167,6 +1171,8 @@ type Turn struct { type ActiveTurn struct { // Turn identifier Id string `json:"id"` + // ISO 8601 timestamp when this turn started. + StartedAt string `json:"startedAt"` // The message that initiated the turn Message Message `json:"message"` // All response content in stream order: text, tool calls, reasoning, and content refs. diff --git a/clients/go/examples/reducers_demo/main.go b/clients/go/examples/reducers_demo/main.go index 7e10b7f0..69c7b4e3 100644 --- a/clients/go/examples/reducers_demo/main.go +++ b/clients/go/examples/reducers_demo/main.go @@ -20,9 +20,10 @@ func main() { actions := []ahptypes.StateAction{ {Value: &ahptypes.ChatTurnStartedAction{ - Type: ahptypes.ActionTypeChatTurnStarted, - TurnId: "t1", - Message: ahptypes.Message{Text: "Hello!", Origin: ahptypes.MessageOrigin{Kind: ahptypes.MessageKindUser}}, + Type: ahptypes.ActionTypeChatTurnStarted, + TurnId: "t1", + StartedAt: "2026-07-09T20:00:00.000Z", + Message: ahptypes.Message{Text: "Hello!", Origin: ahptypes.MessageOrigin{Kind: ahptypes.MessageKindUser}}, }}, {Value: &ahptypes.ChatResponsePartAction{ Type: ahptypes.ActionTypeChatResponsePart, @@ -40,8 +41,9 @@ func main() { Content: "there!", }}, {Value: &ahptypes.ChatTurnCompleteAction{ - Type: ahptypes.ActionTypeChatTurnComplete, - TurnId: "t1", + Type: ahptypes.ActionTypeChatTurnComplete, + TurnId: "t1", + EndedAt: "2026-07-09T20:00:01.000Z", }}, } 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 5edb26e1..049277f5 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -82,6 +82,11 @@ public var currentTimestampProvider: () -> Long = { System.currentTimeMillis() } private fun nowIsoString(): String = Instant.ofEpochMilli(currentTimestampProvider()).toString() +private fun parseTimestampMillis(value: String): Long? { + val millis = runCatching { Instant.parse(value).toEpochMilli() }.getOrNull() ?: return null + return millis.takeIf { it in -8_640_000_000_000_000L..8_640_000_000_000_000L } +} + // ─── Status Bitset Helpers ────────────────────────────────────────────────── /** Bitmask covering the mutually-exclusive activity bits (bits 0–4). */ @@ -332,10 +337,12 @@ private fun updateResponsePart( private fun endTurn( state: ChatState, turnId: String, + endedAt: String, turnState: TurnState, terminalStatus: SessionStatus? = null, error: ErrorInfo? = null, ): ChatState { + val ended = parseTimestampMillis(endedAt) ?: return state val active = state.activeTurn ?: return state if (active.id != turnId) return state @@ -386,8 +393,12 @@ private fun endTurn( ) } + val duration = parseTimestampMillis(active.startedAt) + ?.let { started -> maxOf(0L, ended - started) } val turn = Turn( id = active.id, + startedAt = active.startedAt, + duration = duration, message = active.message, responseParts = finalizedParts, usage = active.usage, @@ -399,7 +410,7 @@ private fun endTurn( turns = state.turns + turn, activeTurn = null, inputRequests = null, - modifiedAt = nowIsoString(), + modifiedAt = endedAt, ) return withoutTurn.copy(status = chatSummaryStatus(withoutTurn, terminalStatus)) } @@ -744,31 +755,36 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when is StateActionChatTurnStarted -> { val a = action.value - val withTurn = state.copy( - activeTurn = ActiveTurn( - id = a.turnId, - message = a.message, - responseParts = emptyList(), - usage = null, - ), - ) - val withStatus = withTurn.copy( - status = withStatusFlag(chatSummaryStatus(withTurn), SessionStatus.IS_READ, false), - modifiedAt = nowIsoString(), - ) - if (a.queuedMessageId == null) { - withStatus + if (parseTimestampMillis(a.startedAt) == null) { + state } else { - var next = withStatus - if (next.steeringMessage?.id == a.queuedMessageId) { - next = next.copy(steeringMessage = null) - } - val queued = next.queuedMessages - if (queued != null) { - val filtered = queued.filter { it.id != a.queuedMessageId } - next = next.copy(queuedMessages = filtered.ifEmpty { null }) + val withTurn = state.copy( + activeTurn = ActiveTurn( + id = a.turnId, + startedAt = a.startedAt, + message = a.message, + responseParts = emptyList(), + usage = null, + ), + ) + val withStatus = withTurn.copy( + status = withStatusFlag(chatSummaryStatus(withTurn), SessionStatus.IS_READ, false), + modifiedAt = a.startedAt, + ) + if (a.queuedMessageId == null) { + withStatus + } else { + var next = withStatus + if (next.steeringMessage?.id == a.queuedMessageId) { + next = next.copy(steeringMessage = null) + } + val queued = next.queuedMessages + if (queued != null) { + val filtered = queued.filter { it.id != a.queuedMessageId } + next = next.copy(queuedMessages = filtered.ifEmpty { null }) + } + next } - next } } @@ -796,13 +812,13 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when } is StateActionChatTurnComplete -> - endTurn(state, action.value.turnId, TurnState.COMPLETE) + endTurn(state, action.value.turnId, action.value.endedAt, TurnState.COMPLETE) is StateActionChatTurnCancelled -> - endTurn(state, action.value.turnId, TurnState.CANCELLED) + endTurn(state, action.value.turnId, action.value.endedAt, TurnState.CANCELLED) is StateActionChatError -> - endTurn(state, action.value.turnId, TurnState.ERROR, SessionStatus.ERROR, action.value.error) + endTurn(state, action.value.turnId, action.value.endedAt, TurnState.ERROR, SessionStatus.ERROR, action.value.error) is StateActionChatActivityChanged -> state.copy(activity = action.value.activity) 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 cd859497..657d9280 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 @@ -290,6 +290,10 @@ data class ChatTurnStartedAction( * Turn identifier */ val turnId: String, + /** + * ISO 8601 timestamp when this turn started. + */ + val startedAt: String, /** * The new message */ @@ -609,6 +613,10 @@ data class ChatTurnCompleteAction( * Turn identifier */ val turnId: String, + /** + * ISO 8601 timestamp when this turn completed. + */ + val endedAt: String, /** * Additional provider-specific metadata for this action. * @@ -629,6 +637,10 @@ data class ChatTurnCancelledAction( * Turn identifier */ val turnId: String, + /** + * ISO 8601 timestamp when this turn was cancelled. + */ + val endedAt: String, /** * Additional provider-specific metadata for this action. * @@ -649,6 +661,10 @@ data class ChatErrorAction( * Turn identifier */ val turnId: String, + /** + * ISO 8601 timestamp when this turn failed. + */ + val endedAt: String, /** * Error details */ 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 babb1ec2..ab8982a9 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 @@ -1576,6 +1576,14 @@ data class Turn( * Turn identifier */ val id: String, + /** + * ISO 8601 timestamp when this turn started. + */ + val startedAt: String? = null, + /** + * Turn duration in milliseconds. + */ + val duration: Long? = null, /** * The message that initiated the turn */ @@ -1607,6 +1615,10 @@ data class ActiveTurn( * Turn identifier */ val id: String, + /** + * ISO 8601 timestamp when this turn started. + */ + val startedAt: String, /** * The message that initiated the turn */ diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt index ba36c542..111a693e 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt @@ -209,18 +209,14 @@ class ReducersTest { } @Test - fun `currentTimestampProvider override flows through to reducer outputs`() { - currentTimestampProvider = { 12345L } - - // The chat reducer stamps `modifiedAt` from the injected timestamp - // provider. (The session reducer no longer stamps a timestamp — the - // host owns the root-channel summary's `modifiedAt`.) + fun `turn start timestamp flows through to reducer outputs`() { val chatResult = chatReducer( newChat(), StateActionChatTurnStarted( ChatTurnStartedAction( type = ActionType.CHAT_TURN_STARTED, turnId = "turn-1", + startedAt = "1970-01-01T00:00:12.345Z", message = userMessage("hello"), ), ), diff --git a/clients/rust/Cargo.lock b/clients/rust/Cargo.lock index b3a0f2ab..7d8aaa1c 100644 --- a/clients/rust/Cargo.lock +++ b/clients/rust/Cargo.lock @@ -8,6 +8,7 @@ version = "0.5.2" dependencies = [ "ahp-types", "ahp-ws", + "chrono", "serde", "serde_json", "thiserror", @@ -45,6 +46,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bitflags" version = "2.11.1" @@ -82,6 +89,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -543,6 +559,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" diff --git a/clients/rust/Cargo.toml b/clients/rust/Cargo.toml index 9c23c7b4..1fdd4ba6 100644 --- a/clients/rust/Cargo.toml +++ b/clients/rust/Cargo.toml @@ -22,5 +22,6 @@ tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } tokio-util = "0.7" futures-util = "0.3" tracing = "0.1" +chrono = { version = "0.4", default-features = false, features = ["std"] } ahp-types = { path = "crates/ahp-types", version = "0.5.2" } ahp = { path = "crates/ahp", version = "0.5.2" } diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index e871381c..605ac97a 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -321,6 +321,8 @@ pub struct SessionDefaultChatChangedAction { pub struct ChatTurnStartedAction { /// Turn identifier pub turn_id: String, + /// ISO 8601 timestamp when this turn started. + pub started_at: String, /// The new message pub message: Message, /// If this turn was auto-started from a queued message, the ID of that message @@ -613,6 +615,8 @@ pub struct ChatToolCallContentChangedAction { pub struct ChatTurnCompleteAction { /// Turn identifier pub turn_id: String, + /// ISO 8601 timestamp when this turn completed. + pub ended_at: String, /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -630,6 +634,8 @@ pub struct ChatTurnCompleteAction { pub struct ChatTurnCancelledAction { /// Turn identifier pub turn_id: String, + /// ISO 8601 timestamp when this turn was cancelled. + pub ended_at: String, /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -647,6 +653,8 @@ pub struct ChatTurnCancelledAction { pub struct ChatErrorAction { /// Turn identifier pub turn_id: String, + /// ISO 8601 timestamp when this turn failed. + pub ended_at: String, /// Error details pub error: ErrorInfo, /// Additional provider-specific metadata for this action. diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 6775f54e..6da14c19 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -1474,6 +1474,12 @@ pub struct SessionConfigState { pub struct Turn { /// Turn identifier pub id: String, + /// ISO 8601 timestamp when this turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option, + /// Turn duration in milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration: Option, /// The message that initiated the turn pub message: Message, /// All response content in stream order: text, tool calls, reasoning, and content refs. @@ -1497,6 +1503,8 @@ pub struct Turn { pub struct ActiveTurn { /// Turn identifier pub id: String, + /// ISO 8601 timestamp when this turn started. + pub started_at: String, /// The message that initiated the turn pub message: Message, /// All response content in stream order: text, tool calls, reasoning, and content refs. diff --git a/clients/rust/crates/ahp/Cargo.toml b/clients/rust/crates/ahp/Cargo.toml index 4608dc74..86aefd8e 100644 --- a/clients/rust/crates/ahp/Cargo.toml +++ b/clients/rust/crates/ahp/Cargo.toml @@ -23,6 +23,7 @@ serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } +chrono = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 4f854ba9..42a59a9d 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -68,6 +68,7 @@ use ahp_types::state::{ ToolCallResponsePart, ToolCallRunningState, ToolCallState, ToolCallStreamingState, Turn, TurnState, }; +use chrono::{DateTime, FixedOffset}; /// What happened when an action was applied. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -275,13 +276,21 @@ fn touch_chat_modified(state: &mut ChatState) { state.modified_at = now_iso(); } +fn parse_timestamp(value: &str) -> Option> { + DateTime::parse_from_rfc3339(value).ok() +} + fn end_turn( state: &mut ChatState, turn_id: &str, + ended_at: &str, turn_state: TurnState, terminal_status: Option, error: Option, ) -> ReduceOutcome { + let Some(ended) = parse_timestamp(ended_at) else { + return ReduceOutcome::NoOp; + }; let Some(active) = state.active_turn.as_ref() else { return ReduceOutcome::NoOp; }; @@ -344,8 +353,16 @@ fn end_turn( }) .collect(); + let duration = parse_timestamp(&active.started_at).and_then(|started| { + ended + .timestamp_millis() + .checked_sub(started.timestamp_millis()) + .map(|duration| duration.max(0)) + }); let turn = Turn { id: active.id, + started_at: Some(active.started_at), + duration, message: active.message, response_parts, usage: active.usage, @@ -355,7 +372,7 @@ fn end_turn( state.turns.push(turn); state.input_requests = None; - touch_chat_modified(state); + state.modified_at = ended_at.to_owned(); state.status = summary_status(state, terminal_status); ReduceOutcome::Applied } @@ -874,15 +891,26 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu active.response_parts.push(a.part.clone()); ReduceOutcome::Applied } - StateAction::ChatTurnComplete(a) => { - end_turn(state, &a.turn_id, TurnState::Complete, None, None) - } - StateAction::ChatTurnCancelled(a) => { - end_turn(state, &a.turn_id, TurnState::Cancelled, None, None) - } + StateAction::ChatTurnComplete(a) => end_turn( + state, + &a.turn_id, + &a.ended_at, + TurnState::Complete, + None, + None, + ), + StateAction::ChatTurnCancelled(a) => end_turn( + state, + &a.turn_id, + &a.ended_at, + TurnState::Cancelled, + None, + None, + ), StateAction::ChatError(a) => end_turn( state, &a.turn_id, + &a.ended_at, TurnState::Error, Some(SessionStatus::Error), Some(a.error.clone()), @@ -1095,14 +1123,18 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu } fn apply_turn_started(state: &mut ChatState, a: &ChatTurnStartedAction) -> ReduceOutcome { + if parse_timestamp(&a.started_at).is_none() { + return ReduceOutcome::NoOp; + } state.active_turn = Some(ActiveTurn { id: a.turn_id.clone(), + started_at: a.started_at.clone(), message: a.message.clone(), response_parts: Vec::new(), usage: None, }); state.status = summary_status(state, None); - touch_chat_modified(state); + state.modified_at = a.started_at.clone(); state.status = with_status_flag(state.status, SessionStatus::IsRead, false); if let Some(qmid) = &a.queued_message_id { @@ -1767,6 +1799,7 @@ mod tests { let mut s = empty_chat("copilot:/s1/chat/1"); let action = StateAction::ChatTurnStarted(ChatTurnStartedAction { turn_id: "t1".into(), + started_at: "2026-07-09T20:00:00.000Z".into(), message: user_message("hi"), queued_message_id: None, meta: None, @@ -1784,6 +1817,7 @@ mod tests { let mut s = empty_chat("copilot:/s1/chat/1"); s.active_turn = Some(ActiveTurn { id: "t1".into(), + started_at: "2026-07-09T20:00:00.000Z".into(), message: user_message("hi"), response_parts: vec![ResponsePart::Markdown(MarkdownResponsePart { id: "p1".into(), @@ -1809,6 +1843,7 @@ mod tests { let mut s = empty_chat("copilot:/s1/chat/1"); s.active_turn = Some(ActiveTurn { id: "t1".into(), + started_at: "2026-07-09T20:00:00.000Z".into(), message: user_message("hi"), response_parts: Vec::new(), usage: None, @@ -1816,6 +1851,7 @@ mod tests { s.status = SessionStatus::InProgress.bits(); let a = StateAction::ChatTurnComplete(ahp_types::actions::ChatTurnCompleteAction { turn_id: "t1".into(), + ended_at: "2026-07-09T20:00:01.000Z".into(), meta: None, }); assert_eq!(apply_action_to_chat(&mut s, &a), ReduceOutcome::Applied); @@ -1885,6 +1921,7 @@ mod tests { // A chat-scoped action is out of scope for the session reducer. let turn = StateAction::ChatTurnComplete(ahp_types::actions::ChatTurnCompleteAction { turn_id: "t1".into(), + ended_at: "2026-07-09T20:00:01.000Z".into(), meta: None, }); assert_eq!( diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index edb1dcdc..516f7a97 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -246,6 +246,8 @@ public struct ChatTurnStartedAction: Codable, Sendable { public var type: ActionType /// Turn identifier public var turnId: String + /// ISO 8601 timestamp when this turn started. + public var startedAt: String /// The new message public var message: Message /// If this turn was auto-started from a queued message, the ID of that message @@ -262,6 +264,7 @@ public struct ChatTurnStartedAction: Codable, Sendable { enum CodingKeys: String, CodingKey { case type case turnId + case startedAt case message case queuedMessageId case meta = "_meta" @@ -270,12 +273,14 @@ public struct ChatTurnStartedAction: Codable, Sendable { public init( type: ActionType, turnId: String, + startedAt: String, message: Message, queuedMessageId: String? = nil, meta: [String: AnyCodable]? = nil ) { self.type = type self.turnId = turnId + self.startedAt = startedAt self.message = message self.queuedMessageId = queuedMessageId self.meta = meta @@ -711,6 +716,8 @@ public struct ChatTurnCompleteAction: Codable, Sendable { public var type: ActionType /// Turn identifier public var turnId: String + /// ISO 8601 timestamp when this turn completed. + public var endedAt: String /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -723,16 +730,19 @@ public struct ChatTurnCompleteAction: Codable, Sendable { enum CodingKeys: String, CodingKey { case type case turnId + case endedAt case meta = "_meta" } public init( type: ActionType, turnId: String, + endedAt: String, meta: [String: AnyCodable]? = nil ) { self.type = type self.turnId = turnId + self.endedAt = endedAt self.meta = meta } } @@ -741,6 +751,8 @@ public struct ChatTurnCancelledAction: Codable, Sendable { public var type: ActionType /// Turn identifier public var turnId: String + /// ISO 8601 timestamp when this turn was cancelled. + public var endedAt: String /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -753,16 +765,19 @@ public struct ChatTurnCancelledAction: Codable, Sendable { enum CodingKeys: String, CodingKey { case type case turnId + case endedAt case meta = "_meta" } public init( type: ActionType, turnId: String, + endedAt: String, meta: [String: AnyCodable]? = nil ) { self.type = type self.turnId = turnId + self.endedAt = endedAt self.meta = meta } } @@ -771,6 +786,8 @@ public struct ChatErrorAction: Codable, Sendable { public var type: ActionType /// Turn identifier public var turnId: String + /// ISO 8601 timestamp when this turn failed. + public var endedAt: String /// Error details public var error: ErrorInfo /// Additional provider-specific metadata for this action. @@ -785,6 +802,7 @@ public struct ChatErrorAction: Codable, Sendable { enum CodingKeys: String, CodingKey { case type case turnId + case endedAt case error case meta = "_meta" } @@ -792,11 +810,13 @@ public struct ChatErrorAction: Codable, Sendable { public init( type: ActionType, turnId: String, + endedAt: String, error: ErrorInfo, meta: [String: AnyCodable]? = nil ) { self.type = type self.turnId = turnId + self.endedAt = endedAt self.error = error 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 9a6990fc..12c63924 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -1412,6 +1412,10 @@ public struct SessionConfigState: Codable, Sendable { public struct Turn: Codable, Sendable { /// Turn identifier public var id: String + /// ISO 8601 timestamp when this turn started. + public var startedAt: String? + /// Turn duration in milliseconds. + public var duration: Int? /// The message that initiated the turn public var message: Message /// All response content in stream order: text, tool calls, reasoning, and content refs. @@ -1428,6 +1432,8 @@ public struct Turn: Codable, Sendable { public init( id: String, + startedAt: String? = nil, + duration: Int? = nil, message: Message, responseParts: [ResponsePart], usage: UsageInfo? = nil, @@ -1435,6 +1441,8 @@ public struct Turn: Codable, Sendable { error: ErrorInfo? = nil ) { self.id = id + self.startedAt = startedAt + self.duration = duration self.message = message self.responseParts = responseParts self.usage = usage @@ -1446,6 +1454,8 @@ public struct Turn: Codable, Sendable { public struct ActiveTurn: Codable, Sendable { /// Turn identifier public var id: String + /// ISO 8601 timestamp when this turn started. + public var startedAt: String /// The message that initiated the turn public var message: Message /// All response content in stream order: text, tool calls, reasoning, and content refs. @@ -1457,11 +1467,13 @@ public struct ActiveTurn: Codable, Sendable { public init( id: String, + startedAt: String, message: Message, responseParts: [ResponsePart], usage: UsageInfo? = nil ) { self.id = id + self.startedAt = startedAt self.message = message self.responseParts = responseParts self.usage = usage diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index f3c955ee..f01364a5 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -17,6 +17,21 @@ private let iso8601TimestampFormatter: ISO8601DateFormatter = { return formatter }() +private let iso8601TimestampFormatterWithoutFractionalSeconds: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter +}() + +private func parseTimestamp(_ value: String) -> Date? { + iso8601TimestampFormatter.date(from: value) + ?? iso8601TimestampFormatterWithoutFractionalSeconds.date(from: value) +} + +private func timestampMilliseconds(_ date: Date) -> Int { + Int(floor(date.timeIntervalSince1970 * 1000)) +} + // MARK: - Status Bitset Helpers /// Bitmask covering the mutually-exclusive activity bits (bits 0–4). @@ -101,10 +116,12 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { // ── Turn Lifecycle ──────────────────────────────────────────────────── case .chatTurnStarted(let a): + guard parseTimestamp(a.startedAt) != nil else { return state } var next = state - next.modifiedAt = currentTimestamp() + next.modifiedAt = a.startedAt next.activeTurn = ActiveTurn( id: a.turnId, + startedAt: a.startedAt, message: a.message, responseParts: [], usage: nil @@ -138,13 +155,13 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { return next case .chatTurnComplete(let a): - return endTurn(state: state, turnId: a.turnId, turnState: .complete) + return endTurn(state: state, turnId: a.turnId, endedAt: a.endedAt, turnState: .complete) case .chatTurnCancelled(let a): - return endTurn(state: state, turnId: a.turnId, turnState: .cancelled) + return endTurn(state: state, turnId: a.turnId, endedAt: a.endedAt, turnState: .cancelled) case .chatError(let a): - return endTurn(state: state, turnId: a.turnId, turnState: .error, terminalStatus: .error, error: a.error) + return endTurn(state: state, turnId: a.turnId, endedAt: a.endedAt, turnState: .error, terminalStatus: .error, error: a.error) case .chatActivityChanged(let a): var next = state @@ -887,10 +904,12 @@ private func upsertInputRequest(state: ChatState, request: ChatInputRequest) -> private func endTurn( state: ChatState, turnId: String, + endedAt: String, turnState: TurnState, terminalStatus: SessionStatus? = nil, error: ErrorInfo? = nil ) -> ChatState { + guard let ended = parseTimestamp(endedAt) else { return state } guard let activeTurn = state.activeTurn, activeTurn.id == turnId else { return state } @@ -940,8 +959,13 @@ private func endTurn( } } + let endedMilliseconds = timestampMilliseconds(ended) + let duration = parseTimestamp(activeTurn.startedAt) + .map { max(0, endedMilliseconds - timestampMilliseconds($0)) } let turn = Turn( id: activeTurn.id, + startedAt: activeTurn.startedAt, + duration: duration, message: activeTurn.message, responseParts: responseParts, usage: activeTurn.usage, @@ -954,7 +978,7 @@ private func endTurn( next.activeTurn = nil next.inputRequests = nil next.status = chatSummaryStatus(next, terminalStatus: terminalStatus) - next.modifiedAt = currentTimestamp() + next.modifiedAt = endedAt return next } diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/NativeReducerTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/NativeReducerTests.swift index 5de270a4..09bae9ea 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/NativeReducerTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/NativeReducerTests.swift @@ -50,6 +50,7 @@ final class NativeReducerTests: XCTestCase { turns: [], activeTurn: ActiveTurn( id: T, + startedAt: "2026-07-09T20:00:00.000Z", message: Message(text: "Hello", origin: MessageOrigin(kind: .user)), responseParts: [], usage: nil diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift index 88924b3e..4d910ee2 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift @@ -46,6 +46,7 @@ final class ReducersTests: XCTestCase { turns: [], activeTurn: ActiveTurn( id: T, + startedAt: "2026-07-09T20:00:00.000Z", message: Message(text: "Hello", origin: MessageOrigin(kind: .user)), responseParts: [], usage: nil @@ -88,7 +89,8 @@ final class ReducersTests: XCTestCase { func testClientDispatchableReturnsTrue() { let action: StateAction = .chatTurnStarted(ChatTurnStartedAction( - type: .chatTurnStarted, turnId: T, message: Message(text: "Hello", origin: MessageOrigin(kind: .user)) + type: .chatTurnStarted, turnId: T, startedAt: "2026-07-09T20:00:00.000Z", + message: Message(text: "Hello", origin: MessageOrigin(kind: .user)) )) XCTAssertTrue(isClientDispatchable(action)) } @@ -111,10 +113,11 @@ final class ReducersTests: XCTestCase { let next = chatReducer( state: state, action: .chatTurnStarted(ChatTurnStartedAction( - type: .chatTurnStarted, turnId: T, message: Message(text: "Hello", origin: MessageOrigin(kind: .user)) + type: .chatTurnStarted, turnId: T, startedAt: "2026-07-09T20:00:00.000Z", + message: Message(text: "Hello", origin: MessageOrigin(kind: .user)) )) ) - XCTAssertGreaterThan(next.modifiedAt, state.modifiedAt) + XCTAssertEqual(next.modifiedAt, "2026-07-09T20:00:00.000Z") } func testTitleChangedUpdatesTitle() { diff --git a/docs/.changes/20260709-turn-timing.json b/docs/.changes/20260709-turn-timing.json new file mode 100644 index 00000000..43d248e7 --- /dev/null +++ b/docs/.changes/20260709-turn-timing.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "Turn lifecycle actions now carry timestamps, and completed turns expose their start time and duration." +} diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 038c7b4c..d8e84ef1 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -649,6 +649,10 @@ "type": "string", "description": "Turn identifier" }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn started." + }, "message": { "$ref": "#/$defs/Message", "description": "The new message" @@ -666,6 +670,7 @@ "required": [ "type", "turnId", + "startedAt", "message" ] }, @@ -1089,6 +1094,10 @@ "type": "string", "description": "Turn identifier" }, + "endedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn completed." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -1097,7 +1106,8 @@ }, "required": [ "type", - "turnId" + "turnId", + "endedAt" ] }, "ChatTurnCancelledAction": { @@ -1111,6 +1121,10 @@ "type": "string", "description": "Turn identifier" }, + "endedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn was cancelled." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -1119,7 +1133,8 @@ }, "required": [ "type", - "turnId" + "turnId", + "endedAt" ] }, "ChatErrorAction": { @@ -1133,6 +1148,10 @@ "type": "string", "description": "Turn identifier" }, + "endedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn failed." + }, "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details" @@ -1146,6 +1165,7 @@ "required": [ "type", "turnId", + "endedAt", "error" ] }, @@ -4779,6 +4799,14 @@ "type": "string", "description": "Turn identifier" }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn started." + }, + "duration": { + "type": "number", + "description": "Turn duration in milliseconds." + }, "message": { "$ref": "#/$defs/Message", "description": "The message that initiated the turn" @@ -4819,6 +4847,10 @@ "type": "string", "description": "Turn identifier" }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn started." + }, "message": { "$ref": "#/$defs/Message", "description": "The message that initiated the turn" @@ -4837,6 +4869,7 @@ }, "required": [ "id", + "startedAt", "message", "responseParts", "usage" diff --git a/schema/commands.schema.json b/schema/commands.schema.json index c1b658c8..4eeeb18f 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -4067,6 +4067,14 @@ "type": "string", "description": "Turn identifier" }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn started." + }, + "duration": { + "type": "number", + "description": "Turn duration in milliseconds." + }, "message": { "$ref": "#/$defs/Message", "description": "The message that initiated the turn" @@ -4107,6 +4115,10 @@ "type": "string", "description": "Turn identifier" }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn started." + }, "message": { "$ref": "#/$defs/Message", "description": "The message that initiated the turn" @@ -4125,6 +4137,7 @@ }, "required": [ "id", + "startedAt", "message", "responseParts", "usage" @@ -6482,6 +6495,10 @@ "type": "string", "description": "Turn identifier" }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn started." + }, "message": { "$ref": "#/$defs/Message", "description": "The new message" @@ -6499,6 +6516,7 @@ "required": [ "type", "turnId", + "startedAt", "message" ] }, @@ -6922,6 +6940,10 @@ "type": "string", "description": "Turn identifier" }, + "endedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn completed." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -6930,7 +6952,8 @@ }, "required": [ "type", - "turnId" + "turnId", + "endedAt" ] }, "ChatTurnCancelledAction": { @@ -6944,6 +6967,10 @@ "type": "string", "description": "Turn identifier" }, + "endedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn was cancelled." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -6952,7 +6979,8 @@ }, "required": [ "type", - "turnId" + "turnId", + "endedAt" ] }, "ChatErrorAction": { @@ -6966,6 +6994,10 @@ "type": "string", "description": "Turn identifier" }, + "endedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn failed." + }, "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details" @@ -6979,6 +7011,7 @@ "required": [ "type", "turnId", + "endedAt", "error" ] }, diff --git a/schema/errors.schema.json b/schema/errors.schema.json index f64961ff..36f341e0 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -2858,6 +2858,14 @@ "type": "string", "description": "Turn identifier" }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn started." + }, + "duration": { + "type": "number", + "description": "Turn duration in milliseconds." + }, "message": { "$ref": "#/$defs/Message", "description": "The message that initiated the turn" @@ -2898,6 +2906,10 @@ "type": "string", "description": "Turn identifier" }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn started." + }, "message": { "$ref": "#/$defs/Message", "description": "The message that initiated the turn" @@ -2916,6 +2928,7 @@ }, "required": [ "id", + "startedAt", "message", "responseParts", "usage" @@ -7393,6 +7406,10 @@ "type": "string", "description": "Turn identifier" }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn started." + }, "message": { "$ref": "#/$defs/Message", "description": "The new message" @@ -7410,6 +7427,7 @@ "required": [ "type", "turnId", + "startedAt", "message" ] }, @@ -7745,6 +7763,10 @@ "type": "string", "description": "Turn identifier" }, + "endedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn completed." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -7753,7 +7775,8 @@ }, "required": [ "type", - "turnId" + "turnId", + "endedAt" ] }, "ChatTurnCancelledAction": { @@ -7767,6 +7790,10 @@ "type": "string", "description": "Turn identifier" }, + "endedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn was cancelled." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -7775,7 +7802,8 @@ }, "required": [ "type", - "turnId" + "turnId", + "endedAt" ] }, "ChatErrorAction": { @@ -7789,6 +7817,10 @@ "type": "string", "description": "Turn identifier" }, + "endedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn failed." + }, "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details" @@ -7802,6 +7834,7 @@ "required": [ "type", "turnId", + "endedAt", "error" ] }, diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 3c534515..6a1f0318 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -3018,6 +3018,14 @@ "type": "string", "description": "Turn identifier" }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn started." + }, + "duration": { + "type": "number", + "description": "Turn duration in milliseconds." + }, "message": { "$ref": "#/$defs/Message", "description": "The message that initiated the turn" @@ -3058,6 +3066,10 @@ "type": "string", "description": "Turn identifier" }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn started." + }, "message": { "$ref": "#/$defs/Message", "description": "The message that initiated the turn" @@ -3076,6 +3088,7 @@ }, "required": [ "id", + "startedAt", "message", "responseParts", "usage" diff --git a/schema/state.schema.json b/schema/state.schema.json index 7a107af7..d9f1ac3c 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -2769,6 +2769,14 @@ "type": "string", "description": "Turn identifier" }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn started." + }, + "duration": { + "type": "number", + "description": "Turn duration in milliseconds." + }, "message": { "$ref": "#/$defs/Message", "description": "The message that initiated the turn" @@ -2809,6 +2817,10 @@ "type": "string", "description": "Turn identifier" }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this turn started." + }, "message": { "$ref": "#/$defs/Message", "description": "The message that initiated the turn" @@ -2827,6 +2839,7 @@ }, "required": [ "id", + "startedAt", "message", "responseParts", "usage" diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index 0fd6b8d4..4ad13f5c 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -65,6 +65,8 @@ export interface ChatTurnStartedAction { type: ActionType.ChatTurnStarted; /** Turn identifier */ turnId: string; + /** ISO 8601 timestamp when this turn started. */ + startedAt: string; /** The new message */ message: Message; /** If this turn was auto-started from a queued message, the ID of that message */ @@ -343,6 +345,8 @@ export interface ChatTurnCompleteAction { type: ActionType.ChatTurnComplete; /** Turn identifier */ turnId: string; + /** ISO 8601 timestamp when this turn completed. */ + endedAt: string; /** * Additional provider-specific metadata for this action. * @@ -366,6 +370,8 @@ export interface ChatTurnCancelledAction { type: ActionType.ChatTurnCancelled; /** Turn identifier */ turnId: string; + /** ISO 8601 timestamp when this turn was cancelled. */ + endedAt: string; /** * Additional provider-specific metadata for this action. * @@ -388,6 +394,8 @@ export interface ChatErrorAction { type: ActionType.ChatError; /** Turn identifier */ turnId: string; + /** ISO 8601 timestamp when this turn failed. */ + endedAt: string; /** Error details */ error: ErrorInfo; /** diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index 23764292..ab8dff9c 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -117,6 +117,7 @@ function endTurn( state: ChatState, turnId: string, turnState: TurnState, + endedAt: string, terminalStatus?: SessionStatus.Error, error?: { errorType: string; message: string; stack?: string }, ): ChatState { @@ -146,8 +147,15 @@ function endTurn( }; }); + const startedAt = Date.parse(active.startedAt); + const completedAt = Date.parse(endedAt); + const duration = Number.isFinite(startedAt) && Number.isFinite(completedAt) + ? Math.max(0, completedAt - startedAt) + : undefined; const turn: Turn = { id: active.id, + startedAt: active.startedAt, + duration, message: active.message, responseParts, usage: active.usage, @@ -159,7 +167,7 @@ function endTurn( ...state, turns: [...state.turns, turn], activeTurn: undefined, - modifiedAt: new Date(Date.now()).toISOString(), + modifiedAt: endedAt, }; delete next.inputRequests; return { @@ -264,6 +272,36 @@ function updateResponsePart( // ─── Chat Reducer ──────────────────────────────────────────────────────────── +function isValidTimestamp(value: string): boolean { + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-](\d{2}):(\d{2}))$/.exec(value); + if (!match) { + return false; + } + + const [, yearText, monthText, dayText, hourText, minuteText, secondText, offsetHourText, offsetMinuteText] = match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const hour = Number(hourText); + const minute = Number(minuteText); + const second = Number(secondText); + const offsetHour = offsetHourText === undefined ? 0 : Number(offsetHourText); + const offsetMinute = offsetMinuteText === undefined ? 0 : Number(offsetMinuteText); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + + return month >= 1 + && month <= 12 + && day >= 1 + && day <= daysInMonth[month - 1] + && hour <= 23 + && minute <= 59 + && second <= 59 + && offsetHour <= 23 + && offsetMinute <= 59 + && Number.isFinite(Date.parse(value)); +} + /** * Pure reducer for chat state. Handles all {@link ChatAction} variants. */ @@ -272,10 +310,15 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st // ── Turn Lifecycle ──────────────────────────────────────────────────── case ActionType.ChatTurnStarted: { + if (!isValidTimestamp(action.startedAt)) { + log?.(`Ignoring ChatTurnStarted with invalid startedAt: ${action.startedAt}`); + return state; + } let next: ChatState = { ...state, activeTurn: { id: action.turnId, + startedAt: action.startedAt, message: action.message, responseParts: [], usage: undefined, @@ -284,7 +327,7 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st next = { ...next, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), - modifiedAt: new Date(Date.now()).toISOString(), + modifiedAt: action.startedAt, }; // If this turn was auto-started from a pending message, remove it @@ -322,13 +365,25 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st }; case ActionType.ChatTurnComplete: - return endTurn(state, action.turnId, TurnState.Complete); + if (!isValidTimestamp(action.endedAt)) { + log?.(`Ignoring ChatTurnComplete with invalid endedAt: ${action.endedAt}`); + return state; + } + return endTurn(state, action.turnId, TurnState.Complete, action.endedAt); case ActionType.ChatTurnCancelled: - return endTurn(state, action.turnId, TurnState.Cancelled); + if (!isValidTimestamp(action.endedAt)) { + log?.(`Ignoring ChatTurnCancelled with invalid endedAt: ${action.endedAt}`); + return state; + } + return endTurn(state, action.turnId, TurnState.Cancelled, action.endedAt); case ActionType.ChatError: - return endTurn(state, action.turnId, TurnState.Error, SessionStatus.Error, action.error); + if (!isValidTimestamp(action.endedAt)) { + log?.(`Ignoring ChatError with invalid endedAt: ${action.endedAt}`); + return state; + } + return endTurn(state, action.turnId, TurnState.Error, action.endedAt, SessionStatus.Error, action.error); case ActionType.ChatActivityChanged: return { ...state, activity: action.activity }; diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index 00e6f689..9dff723f 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -504,6 +504,10 @@ export const enum MessageAttachmentKind { export interface Turn { /** Turn identifier */ id: string; + /** ISO 8601 timestamp when this turn started. */ + startedAt?: string; + /** Turn duration in milliseconds. */ + duration?: number; /** The message that initiated the turn */ message: Message; /** @@ -529,6 +533,8 @@ export interface Turn { export interface ActiveTurn { /** Turn identifier */ id: string; + /** ISO 8601 timestamp when this turn started. */ + startedAt: string; /** The message that initiated the turn */ message: Message; /** diff --git a/types/reducers.test.ts b/types/reducers.test.ts index f3a8c7de..0d04b739 100644 --- a/types/reducers.test.ts +++ b/types/reducers.test.ts @@ -211,7 +211,7 @@ describe('IS_CLIENT_DISPATCHABLE', () => { describe('isClientDispatchable', () => { it('returns true for client-dispatchable actions', () => { - const action = { type: ActionType.ChatTurnStarted, turnId: 't', message: { text: 'Hello', origin: { kind: MessageKind.User } } } as const; + const action = { type: ActionType.ChatTurnStarted, turnId: 't', startedAt: '2026-07-10T00:00:00.000Z', message: { text: 'Hello', origin: { kind: MessageKind.User } } } as const; assert.equal(isClientDispatchable(action), true); }); diff --git a/types/test-cases/reducers/005-session-turnstarted.json b/types/test-cases/reducers/005-session-turnstarted.json index 3c3576ad..0fb745ce 100644 --- a/types/test-cases/reducers/005-session-turnstarted.json +++ b/types/test-cases/reducers/005-session-turnstarted.json @@ -12,6 +12,7 @@ { "type": "chat/turnStarted", "turnId": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -24,6 +25,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -36,6 +38,6 @@ "resource": "copilot:/test-session", "title": "Test Session", "status": 8, - "modifiedAt": "1970-01-01T00:00:09.999Z" + "modifiedAt": "1970-01-01T00:00:01.000Z" } } diff --git a/types/test-cases/reducers/006-turnstarted-with-queuedmessageid-removes-from-queuedmessages.json b/types/test-cases/reducers/006-turnstarted-with-queuedmessageid-removes-from-queuedmessages.json index 8636d1b2..1977a91a 100644 --- a/types/test-cases/reducers/006-turnstarted-with-queuedmessageid-removes-from-queuedmessages.json +++ b/types/test-cases/reducers/006-turnstarted-with-queuedmessageid-removes-from-queuedmessages.json @@ -32,6 +32,7 @@ { "type": "chat/turnStarted", "turnId": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "First", "origin": { @@ -45,6 +46,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "First", "origin": { @@ -68,6 +70,6 @@ "resource": "copilot:/test-session", "title": "Test Session", "status": 8, - "modifiedAt": "1970-01-01T00:00:09.999Z" + "modifiedAt": "1970-01-01T00:00:01.000Z" } } diff --git a/types/test-cases/reducers/007-turnstarted-with-queuedmessageid-removes-last-queued-message.json b/types/test-cases/reducers/007-turnstarted-with-queuedmessageid-removes-last-queued-message.json index 15b4ecf4..faad3e50 100644 --- a/types/test-cases/reducers/007-turnstarted-with-queuedmessageid-removes-last-queued-message.json +++ b/types/test-cases/reducers/007-turnstarted-with-queuedmessageid-removes-last-queued-message.json @@ -23,6 +23,7 @@ { "type": "chat/turnStarted", "turnId": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Only", "origin": { @@ -36,6 +37,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Only", "origin": { @@ -49,6 +51,6 @@ "resource": "copilot:/test-session", "title": "Test Session", "status": 8, - "modifiedAt": "1970-01-01T00:00:09.999Z" + "modifiedAt": "1970-01-01T00:00:01.000Z" } } diff --git a/types/test-cases/reducers/008-turnstarted-with-queuedmessageid-removes-matching-steering-message.json b/types/test-cases/reducers/008-turnstarted-with-queuedmessageid-removes-matching-steering-message.json index c955affc..022860aa 100644 --- a/types/test-cases/reducers/008-turnstarted-with-queuedmessageid-removes-matching-steering-message.json +++ b/types/test-cases/reducers/008-turnstarted-with-queuedmessageid-removes-matching-steering-message.json @@ -21,6 +21,7 @@ { "type": "chat/turnStarted", "turnId": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Steer", "origin": { @@ -34,6 +35,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Steer", "origin": { @@ -47,6 +49,6 @@ "resource": "copilot:/test-session", "title": "Test Session", "status": 8, - "modifiedAt": "1970-01-01T00:00:09.999Z" + "modifiedAt": "1970-01-01T00:00:01.000Z" } } diff --git a/types/test-cases/reducers/009-turnstarted-without-queuedmessageid-does-not-touch-pending-messages.json b/types/test-cases/reducers/009-turnstarted-without-queuedmessageid-does-not-touch-pending-messages.json index 4b1418e5..77bf2b7a 100644 --- a/types/test-cases/reducers/009-turnstarted-without-queuedmessageid-does-not-touch-pending-messages.json +++ b/types/test-cases/reducers/009-turnstarted-without-queuedmessageid-does-not-touch-pending-messages.json @@ -32,6 +32,7 @@ { "type": "chat/turnStarted", "turnId": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -44,6 +45,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -76,6 +78,6 @@ "resource": "copilot:/test-session", "title": "Test Session", "status": 8, - "modifiedAt": "1970-01-01T00:00:09.999Z" + "modifiedAt": "1970-01-01T00:00:01.000Z" } } diff --git a/types/test-cases/reducers/010-session-delta-appends-content.json b/types/test-cases/reducers/010-session-delta-appends-content.json index f4cefe7e..69d38431 100644 --- a/types/test-cases/reducers/010-session-delta-appends-content.json +++ b/types/test-cases/reducers/010-session-delta-appends-content.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -46,6 +47,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/011-session-delta-with-wrong-turnid-is-no-op.json b/types/test-cases/reducers/011-session-delta-with-wrong-turnid-is-no-op.json index c7fef529..8575b05e 100644 --- a/types/test-cases/reducers/011-session-delta-with-wrong-turnid-is-no-op.json +++ b/types/test-cases/reducers/011-session-delta-with-wrong-turnid-is-no-op.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -37,6 +38,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/013-session-responsepart-adds-to-responseparts.json b/types/test-cases/reducers/013-session-responsepart-adds-to-responseparts.json index 5ea45c46..0feba633 100644 --- a/types/test-cases/reducers/013-session-responsepart-adds-to-responseparts.json +++ b/types/test-cases/reducers/013-session-responsepart-adds-to-responseparts.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -34,6 +35,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json b/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json index 2e4f14b9..a0cf3243 100644 --- a/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json +++ b/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -37,13 +38,16 @@ }, { "type": "chat/turnComplete", - "turnId": "turn-1" + "turnId": "turn-1", + "endedAt": "1970-01-01T00:00:09.999Z" } ], "expected": { "turns": [ { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 8999, "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json b/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json index 61677b59..a5bb9a97 100644 --- a/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json +++ b/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -22,13 +23,16 @@ "actions": [ { "type": "chat/turnCancelled", - "turnId": "turn-1" + "turnId": "turn-1", + "endedAt": "1970-01-01T00:00:09.999Z" } ], "expected": { "turns": [ { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 8999, "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json b/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json index 40c41bbd..5863a121 100644 --- a/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json +++ b/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -23,6 +24,7 @@ { "type": "chat/error", "turnId": "turn-1", + "endedAt": "1970-01-01T00:00:09.999Z", "error": { "errorType": "runtime", "message": "Something broke" @@ -33,6 +35,8 @@ "turns": [ { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 8999, "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json b/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json index 51b4a653..c1cec0e1 100644 --- a/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json +++ b/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -29,13 +30,16 @@ }, { "type": "chat/turnComplete", - "turnId": "turn-1" + "turnId": "turn-1", + "endedAt": "1970-01-01T00:00:09.999Z" } ], "expected": { "turns": [ { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 8999, "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/018-turncomplete-with-wrong-turnid-is-no-op.json b/types/test-cases/reducers/018-turncomplete-with-wrong-turnid-is-no-op.json index 42df2388..5d15fb14 100644 --- a/types/test-cases/reducers/018-turncomplete-with-wrong-turnid-is-no-op.json +++ b/types/test-cases/reducers/018-turncomplete-with-wrong-turnid-is-no-op.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -22,13 +23,15 @@ "actions": [ { "type": "chat/turnComplete", - "turnId": "wrong-turn" + "turnId": "wrong-turn", + "endedAt": "1970-01-01T00:00:09.999Z" } ], "expected": { "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/019-tool-call-full-lifecycle-start-delta-ready-confirmed-complete.json b/types/test-cases/reducers/019-tool-call-full-lifecycle-start-delta-ready-confirmed-complete.json index ca778718..09391579 100644 --- a/types/test-cases/reducers/019-tool-call-full-lifecycle-start-delta-ready-confirmed-complete.json +++ b/types/test-cases/reducers/019-tool-call-full-lifecycle-start-delta-ready-confirmed-complete.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -63,6 +64,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/020-tool-call-ready-with-auto-confirm-transitions-to-running.json b/types/test-cases/reducers/020-tool-call-ready-with-auto-confirm-transitions-to-running.json index b6b84c22..c8577e42 100644 --- a/types/test-cases/reducers/020-tool-call-ready-with-auto-confirm-transitions-to-running.json +++ b/types/test-cases/reducers/020-tool-call-ready-with-auto-confirm-transitions-to-running.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -39,6 +40,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/021-tool-call-denied-transitions-to-cancelled.json b/types/test-cases/reducers/021-tool-call-denied-transitions-to-cancelled.json index 99550ee7..69bdd0cd 100644 --- a/types/test-cases/reducers/021-tool-call-denied-transitions-to-cancelled.json +++ b/types/test-cases/reducers/021-tool-call-denied-transitions-to-cancelled.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -45,6 +46,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/022-tool-call-result-confirmation-pending-approved.json b/types/test-cases/reducers/022-tool-call-result-confirmation-pending-approved.json index 9ed72edb..d5b5c1f2 100644 --- a/types/test-cases/reducers/022-tool-call-result-confirmation-pending-approved.json +++ b/types/test-cases/reducers/022-tool-call-result-confirmation-pending-approved.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -55,6 +56,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/023-tool-call-result-denied-cancelled-with-result-denied-reason.json b/types/test-cases/reducers/023-tool-call-result-denied-cancelled-with-result-denied-reason.json index 40782570..563b8dd0 100644 --- a/types/test-cases/reducers/023-tool-call-result-denied-cancelled-with-result-denied-reason.json +++ b/types/test-cases/reducers/023-tool-call-result-denied-cancelled-with-result-denied-reason.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -55,6 +56,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/024-tool-call-complete-from-pending-confirmation-defaults-confirmed.json b/types/test-cases/reducers/024-tool-call-complete-from-pending-confirmation-defaults-confirmed.json index fc5c96d7..e5b9f540 100644 --- a/types/test-cases/reducers/024-tool-call-complete-from-pending-confirmation-defaults-confirmed.json +++ b/types/test-cases/reducers/024-tool-call-complete-from-pending-confirmation-defaults-confirmed.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -47,6 +48,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/025-tool-call-actions-for-unknown-toolcallid-are-no-op.json b/types/test-cases/reducers/025-tool-call-actions-for-unknown-toolcallid-are-no-op.json index b4c0d1d9..543212f6 100644 --- a/types/test-cases/reducers/025-tool-call-actions-for-unknown-toolcallid-are-no-op.json +++ b/types/test-cases/reducers/025-tool-call-actions-for-unknown-toolcallid-are-no-op.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -31,6 +32,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/026-toolcallready-transitions-running-tool-back-to-pending-confirmation.json b/types/test-cases/reducers/026-toolcallready-transitions-running-tool-back-to-pending-confirmation.json index 86458d85..94f7f058 100644 --- a/types/test-cases/reducers/026-toolcallready-transitions-running-tool-back-to-pending-confirmation.json +++ b/types/test-cases/reducers/026-toolcallready-transitions-running-tool-back-to-pending-confirmation.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -49,6 +50,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/027-toolcallready-re-confirmation-approved-transitions-back-to-running.json b/types/test-cases/reducers/027-toolcallready-re-confirmation-approved-transitions-back-to-running.json index 334292b2..880caf71 100644 --- a/types/test-cases/reducers/027-toolcallready-re-confirmation-approved-transitions-back-to-running.json +++ b/types/test-cases/reducers/027-toolcallready-re-confirmation-approved-transitions-back-to-running.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -52,6 +53,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/028-toolcallready-re-confirmation-denied-transitions-to-cancelled.json b/types/test-cases/reducers/028-toolcallready-re-confirmation-denied-transitions-to-cancelled.json index cdc46c27..414ae35d 100644 --- a/types/test-cases/reducers/028-toolcallready-re-confirmation-denied-transitions-to-cancelled.json +++ b/types/test-cases/reducers/028-toolcallready-re-confirmation-denied-transitions-to-cancelled.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -52,6 +53,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/029-toolcallready-ignores-non-streaming-non-running-tool-calls.json b/types/test-cases/reducers/029-toolcallready-ignores-non-streaming-non-running-tool-calls.json index 9c72391a..d7e4acd6 100644 --- a/types/test-cases/reducers/029-toolcallready-ignores-non-streaming-non-running-tool-calls.json +++ b/types/test-cases/reducers/029-toolcallready-ignores-non-streaming-non-running-tool-calls.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -47,6 +48,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/031-session-usage-updates-usage-on-active-turn.json b/types/test-cases/reducers/031-session-usage-updates-usage-on-active-turn.json index c9cd4ec4..634f8b2f 100644 --- a/types/test-cases/reducers/031-session-usage-updates-usage-on-active-turn.json +++ b/types/test-cases/reducers/031-session-usage-updates-usage-on-active-turn.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -33,6 +34,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/032-session-reasoning-appends-reasoning-content.json b/types/test-cases/reducers/032-session-reasoning-appends-reasoning-content.json index 40338a9c..a9052ee6 100644 --- a/types/test-cases/reducers/032-session-reasoning-appends-reasoning-content.json +++ b/types/test-cases/reducers/032-session-reasoning-appends-reasoning-content.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -46,6 +47,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/067-session-truncated-drops-active-turn.json b/types/test-cases/reducers/067-session-truncated-drops-active-turn.json index d5dde94f..63c00236 100644 --- a/types/test-cases/reducers/067-session-truncated-drops-active-turn.json +++ b/types/test-cases/reducers/067-session-truncated-drops-active-turn.json @@ -30,6 +30,7 @@ ], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/068-session-truncated-drops-active-turn-even-when-clearing-all.json b/types/test-cases/reducers/068-session-truncated-drops-active-turn-even-when-clearing-all.json index e9435f3c..2912d048 100644 --- a/types/test-cases/reducers/068-session-truncated-drops-active-turn-even-when-clearing-all.json +++ b/types/test-cases/reducers/068-session-truncated-drops-active-turn-even-when-clearing-all.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json b/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json index 8225df98..8343b1d4 100644 --- a/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json +++ b/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json @@ -12,6 +12,7 @@ { "type": "chat/turnStarted", "turnId": "t1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Fix the bug", "origin": { @@ -137,13 +138,16 @@ }, { "type": "chat/turnComplete", - "turnId": "t1" + "turnId": "t1", + "endedAt": "1970-01-01T00:00:09.999Z" } ], "expected": { "turns": [ { "id": "t1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 8999, "message": { "text": "Fix the bug", "origin": { diff --git a/types/test-cases/reducers/075-turnstarted-clears-isread.json b/types/test-cases/reducers/075-turnstarted-clears-isread.json index 80005421..f7791425 100644 --- a/types/test-cases/reducers/075-turnstarted-clears-isread.json +++ b/types/test-cases/reducers/075-turnstarted-clears-isread.json @@ -12,6 +12,7 @@ { "type": "chat/turnStarted", "turnId": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -24,6 +25,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -36,6 +38,6 @@ "resource": "copilot:/test-session", "title": "Test Session", "status": 8, - "modifiedAt": "1970-01-01T00:00:09.999Z" + "modifiedAt": "1970-01-01T00:00:01.000Z" } } diff --git a/types/test-cases/reducers/084-toolcall-contentchanged-updates-running.json b/types/test-cases/reducers/084-toolcall-contentchanged-updates-running.json index fdd86bb8..2cb78504 100644 --- a/types/test-cases/reducers/084-toolcall-contentchanged-updates-running.json +++ b/types/test-cases/reducers/084-toolcall-contentchanged-updates-running.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -51,6 +52,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/085-toolcall-contentchanged-noop-non-running.json b/types/test-cases/reducers/085-toolcall-contentchanged-noop-non-running.json index 84740852..18582ffe 100644 --- a/types/test-cases/reducers/085-toolcall-contentchanged-noop-non-running.json +++ b/types/test-cases/reducers/085-toolcall-contentchanged-noop-non-running.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -53,6 +54,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/086-toolcall-contentchanged-replaces-existing.json b/types/test-cases/reducers/086-toolcall-contentchanged-replaces-existing.json index 171fafa2..90507115 100644 --- a/types/test-cases/reducers/086-toolcall-contentchanged-replaces-existing.json +++ b/types/test-cases/reducers/086-toolcall-contentchanged-replaces-existing.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -62,6 +63,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/090-toolcalldelta-wrong-turnid-is-no-op.json b/types/test-cases/reducers/090-toolcalldelta-wrong-turnid-is-no-op.json index 08773d64..b640dc88 100644 --- a/types/test-cases/reducers/090-toolcalldelta-wrong-turnid-is-no-op.json +++ b/types/test-cases/reducers/090-toolcalldelta-wrong-turnid-is-no-op.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -42,6 +43,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/091-responsepart-wrong-turnid-is-no-op.json b/types/test-cases/reducers/091-responsepart-wrong-turnid-is-no-op.json index db7e3c87..457e5f84 100644 --- a/types/test-cases/reducers/091-responsepart-wrong-turnid-is-no-op.json +++ b/types/test-cases/reducers/091-responsepart-wrong-turnid-is-no-op.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -34,6 +35,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/092-toolcallstart-wrong-turnid-is-no-op.json b/types/test-cases/reducers/092-toolcallstart-wrong-turnid-is-no-op.json index 64d7f700..e6f35b90 100644 --- a/types/test-cases/reducers/092-toolcallstart-wrong-turnid-is-no-op.json +++ b/types/test-cases/reducers/092-toolcallstart-wrong-turnid-is-no-op.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -32,6 +33,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/093-usage-wrong-turnid-is-no-op.json b/types/test-cases/reducers/093-usage-wrong-turnid-is-no-op.json index cb26096f..fa12e805 100644 --- a/types/test-cases/reducers/093-usage-wrong-turnid-is-no-op.json +++ b/types/test-cases/reducers/093-usage-wrong-turnid-is-no-op.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -33,6 +34,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/094-delta-nonexistent-partid-is-no-op.json b/types/test-cases/reducers/094-delta-nonexistent-partid-is-no-op.json index 7c564a93..964e516b 100644 --- a/types/test-cases/reducers/094-delta-nonexistent-partid-is-no-op.json +++ b/types/test-cases/reducers/094-delta-nonexistent-partid-is-no-op.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -37,6 +38,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/095-toolcalldelta-wrong-status-is-no-op.json b/types/test-cases/reducers/095-toolcalldelta-wrong-status-is-no-op.json index cdbaab61..93a0910e 100644 --- a/types/test-cases/reducers/095-toolcalldelta-wrong-status-is-no-op.json +++ b/types/test-cases/reducers/095-toolcalldelta-wrong-status-is-no-op.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -45,6 +46,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/096-toolcallconfirmed-wrong-status-is-no-op.json b/types/test-cases/reducers/096-toolcallconfirmed-wrong-status-is-no-op.json index a4472603..f616ee6b 100644 --- a/types/test-cases/reducers/096-toolcallconfirmed-wrong-status-is-no-op.json +++ b/types/test-cases/reducers/096-toolcallconfirmed-wrong-status-is-no-op.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -46,6 +47,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/097-toolcallcomplete-wrong-status-is-no-op.json b/types/test-cases/reducers/097-toolcallcomplete-wrong-status-is-no-op.json index b647b7ba..f4bc585f 100644 --- a/types/test-cases/reducers/097-toolcallcomplete-wrong-status-is-no-op.json +++ b/types/test-cases/reducers/097-toolcallcomplete-wrong-status-is-no-op.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -50,6 +51,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/098-toolcallresultconfirmed-wrong-status-is-no-op.json b/types/test-cases/reducers/098-toolcallresultconfirmed-wrong-status-is-no-op.json index a85b5135..44f6c44d 100644 --- a/types/test-cases/reducers/098-toolcallresultconfirmed-wrong-status-is-no-op.json +++ b/types/test-cases/reducers/098-toolcallresultconfirmed-wrong-status-is-no-op.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -45,6 +46,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json b/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json index cb64c47b..8e04a242 100644 --- a/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json +++ b/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -36,13 +37,16 @@ "actions": [ { "type": "chat/turnComplete", - "turnId": "turn-1" + "turnId": "turn-1", + "endedAt": "1970-01-01T00:00:09.999Z" } ], "expected": { "turns": [ { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 8999, "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/100-delta-targeting-toolcall-partid-is-no-op.json b/types/test-cases/reducers/100-delta-targeting-toolcall-partid-is-no-op.json index b96319e0..2eee6a8d 100644 --- a/types/test-cases/reducers/100-delta-targeting-toolcall-partid-is-no-op.json +++ b/types/test-cases/reducers/100-delta-targeting-toolcall-partid-is-no-op.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -45,6 +46,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/101-toolcalldelta-without-invocationmessage.json b/types/test-cases/reducers/101-toolcalldelta-without-invocationmessage.json index a0b381c7..f8c0e1f5 100644 --- a/types/test-cases/reducers/101-toolcalldelta-without-invocationmessage.json +++ b/types/test-cases/reducers/101-toolcalldelta-without-invocationmessage.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -43,6 +44,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/102-reasoning-targeting-non-reasoning-is-no-op.json b/types/test-cases/reducers/102-reasoning-targeting-non-reasoning-is-no-op.json index f2121787..a07517e0 100644 --- a/types/test-cases/reducers/102-reasoning-targeting-non-reasoning-is-no-op.json +++ b/types/test-cases/reducers/102-reasoning-targeting-non-reasoning-is-no-op.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -37,6 +38,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/103-delta-skips-parts-without-id.json b/types/test-cases/reducers/103-delta-skips-parts-without-id.json index c205564e..4e83b722 100644 --- a/types/test-cases/reducers/103-delta-skips-parts-without-id.json +++ b/types/test-cases/reducers/103-delta-skips-parts-without-id.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -40,6 +41,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/105-session-input-full-draft-and-complete-flow.json b/types/test-cases/reducers/105-session-input-full-draft-and-complete-flow.json index db553adc..88fdff59 100644 --- a/types/test-cases/reducers/105-session-input-full-draft-and-complete-flow.json +++ b/types/test-cases/reducers/105-session-input-full-draft-and-complete-flow.json @@ -12,6 +12,7 @@ { "type": "chat/turnStarted", "turnId": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -82,6 +83,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/106-session-input-requested-with-drafts-status.json b/types/test-cases/reducers/106-session-input-requested-with-drafts-status.json index bd66b38c..102076bb 100644 --- a/types/test-cases/reducers/106-session-input-requested-with-drafts-status.json +++ b/types/test-cases/reducers/106-session-input-requested-with-drafts-status.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -51,6 +52,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json b/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json index b7c5236c..62ba2446 100644 --- a/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json +++ b/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -40,13 +41,16 @@ "actions": [ { "type": "chat/turnComplete", - "turnId": "turn-1" + "turnId": "turn-1", + "endedAt": "1970-01-01T00:00:09.999Z" } ], "expected": { "turns": [ { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 8999, "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/108-session-input-upsert-and-clear-answer.json b/types/test-cases/reducers/108-session-input-upsert-and-clear-answer.json index 64523607..548373c6 100644 --- a/types/test-cases/reducers/108-session-input-upsert-and-clear-answer.json +++ b/types/test-cases/reducers/108-session-input-upsert-and-clear-answer.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -64,6 +65,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/110-session-input-completion-and-truncation-filtering.json b/types/test-cases/reducers/110-session-input-completion-and-truncation-filtering.json index 8987abf0..676d4c21 100644 --- a/types/test-cases/reducers/110-session-input-completion-and-truncation-filtering.json +++ b/types/test-cases/reducers/110-session-input-completion-and-truncation-filtering.json @@ -30,6 +30,7 @@ ], "activeTurn": { "id": "active", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Active", "origin": { diff --git a/types/test-cases/reducers/111-toolcall-pending-confirmation-sets-input-needed-status.json b/types/test-cases/reducers/111-toolcall-pending-confirmation-sets-input-needed-status.json index 5c32932a..fbf03a73 100644 --- a/types/test-cases/reducers/111-toolcall-pending-confirmation-sets-input-needed-status.json +++ b/types/test-cases/reducers/111-toolcall-pending-confirmation-sets-input-needed-status.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -39,6 +40,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/112-toolcall-pending-result-confirmation-sets-input-needed-status.json b/types/test-cases/reducers/112-toolcall-pending-result-confirmation-sets-input-needed-status.json index 1d112bdd..0b8153ce 100644 --- a/types/test-cases/reducers/112-toolcall-pending-result-confirmation-sets-input-needed-status.json +++ b/types/test-cases/reducers/112-toolcall-pending-result-confirmation-sets-input-needed-status.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -49,6 +50,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/127-toolcallready-with-confirmation-options.json b/types/test-cases/reducers/127-toolcallready-with-confirmation-options.json index 3a58c403..0822e2fc 100644 --- a/types/test-cases/reducers/127-toolcallready-with-confirmation-options.json +++ b/types/test-cases/reducers/127-toolcallready-with-confirmation-options.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -64,6 +65,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/128-toolcallconfirmed-approved-with-selectedoption.json b/types/test-cases/reducers/128-toolcallconfirmed-approved-with-selectedoption.json index 09c87fde..17caaa65 100644 --- a/types/test-cases/reducers/128-toolcallconfirmed-approved-with-selectedoption.json +++ b/types/test-cases/reducers/128-toolcallconfirmed-approved-with-selectedoption.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -66,6 +67,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/129-toolcallconfirmed-denied-with-selectedoption.json b/types/test-cases/reducers/129-toolcallconfirmed-denied-with-selectedoption.json index 3e2254fc..0409698e 100644 --- a/types/test-cases/reducers/129-toolcallconfirmed-denied-with-selectedoption.json +++ b/types/test-cases/reducers/129-toolcallconfirmed-denied-with-selectedoption.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -67,6 +68,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/130-selectedoption-carries-through-to-completed.json b/types/test-cases/reducers/130-selectedoption-carries-through-to-completed.json index 8909bac5..31cfac14 100644 --- a/types/test-cases/reducers/130-selectedoption-carries-through-to-completed.json +++ b/types/test-cases/reducers/130-selectedoption-carries-through-to-completed.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -69,6 +70,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/131-selectedoption-carries-through-result-confirmation.json b/types/test-cases/reducers/131-selectedoption-carries-through-result-confirmation.json index 6d17774a..3a823536 100644 --- a/types/test-cases/reducers/131-selectedoption-carries-through-result-confirmation.json +++ b/types/test-cases/reducers/131-selectedoption-carries-through-result-confirmation.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -70,6 +71,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/132-selectedoption-carries-through-result-denied.json b/types/test-cases/reducers/132-selectedoption-carries-through-result-denied.json index 805add3b..871fff31 100644 --- a/types/test-cases/reducers/132-selectedoption-carries-through-result-denied.json +++ b/types/test-cases/reducers/132-selectedoption-carries-through-result-denied.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -70,6 +71,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/158-toolcallconfirmed-approved-with-editedtoolinput-overrides-original.json b/types/test-cases/reducers/158-toolcallconfirmed-approved-with-editedtoolinput-overrides-original.json index d24fd7de..4fd274f3 100644 --- a/types/test-cases/reducers/158-toolcallconfirmed-approved-with-editedtoolinput-overrides-original.json +++ b/types/test-cases/reducers/158-toolcallconfirmed-approved-with-editedtoolinput-overrides-original.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -56,6 +57,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json b/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json index df65d14c..fa43ea16 100644 --- a/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json +++ b/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json @@ -15,6 +15,7 @@ { "type": "chat/turnStarted", "turnId": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -39,13 +40,16 @@ }, { "type": "chat/turnComplete", - "turnId": "turn-1" + "turnId": "turn-1", + "endedAt": "1970-01-01T00:00:09.999Z" } ], "expected": { "turns": [ { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 8999, "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/163-toolcallstart-carries-mcp-contributor-through-lifecycle.json b/types/test-cases/reducers/163-toolcallstart-carries-mcp-contributor-through-lifecycle.json index a897e5e7..a01886f2 100644 --- a/types/test-cases/reducers/163-toolcallstart-carries-mcp-contributor-through-lifecycle.json +++ b/types/test-cases/reducers/163-toolcallstart-carries-mcp-contributor-through-lifecycle.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -53,6 +54,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/220-toolcall-actions-update-meta.json b/types/test-cases/reducers/220-toolcall-actions-update-meta.json index e3b7e797..2eebdd14 100644 --- a/types/test-cases/reducers/220-toolcall-actions-update-meta.json +++ b/types/test-cases/reducers/220-toolcall-actions-update-meta.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { @@ -216,6 +217,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hello", "origin": { diff --git a/types/test-cases/reducers/235-session-input-completion-records-declined-request-in-turn.json b/types/test-cases/reducers/235-session-input-completion-records-declined-request-in-turn.json index 968e563c..3fd17ebc 100644 --- a/types/test-cases/reducers/235-session-input-completion-records-declined-request-in-turn.json +++ b/types/test-cases/reducers/235-session-input-completion-records-declined-request-in-turn.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Review", "origin": { @@ -63,6 +64,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Review", "origin": { diff --git a/types/test-cases/reducers/237-session-input-completion-no-op-unknown-id-with-open-requests.json b/types/test-cases/reducers/237-session-input-completion-no-op-unknown-id-with-open-requests.json index 231852ff..77d568c8 100644 --- a/types/test-cases/reducers/237-session-input-completion-no-op-unknown-id-with-open-requests.json +++ b/types/test-cases/reducers/237-session-input-completion-no-op-unknown-id-with-open-requests.json @@ -5,6 +5,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hi", "origin": { @@ -36,6 +37,7 @@ "turns": [], "activeTurn": { "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", "message": { "text": "Hi", "origin": { diff --git a/types/test-cases/reducers/238-invalid-turn-start-timestamp-is-no-op.json b/types/test-cases/reducers/238-invalid-turn-start-timestamp-is-no-op.json new file mode 100644 index 00000000..bc6f6d62 --- /dev/null +++ b/types/test-cases/reducers/238-invalid-turn-start-timestamp-is-no-op.json @@ -0,0 +1,31 @@ +{ + "description": "chat/turnStarted with an invalid timestamp is a no-op", + "reducer": "chat", + "initial": { + "turns": [], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + }, + "actions": [ + { + "type": "chat/turnStarted", + "turnId": "turn-1", + "startedAt": "July 9 2026 20:00:00", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + } + } + ], + "expected": { + "turns": [], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + } +} diff --git a/types/test-cases/reducers/239-invalid-turn-end-timestamp-is-no-op.json b/types/test-cases/reducers/239-invalid-turn-end-timestamp-is-no-op.json new file mode 100644 index 00000000..78d664f9 --- /dev/null +++ b/types/test-cases/reducers/239-invalid-turn-end-timestamp-is-no-op.json @@ -0,0 +1,49 @@ +{ + "description": "chat/turnComplete with an invalid timestamp is a no-op", + "reducer": "chat", + "initial": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:01.000Z" + }, + "actions": [ + { + "type": "chat/turnComplete", + "turnId": "turn-1", + "endedAt": "2026-07-09T20:00:00.000" + } + ], + "expected": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:01.000Z" + } +} diff --git a/types/test-cases/reducers/240-turn-duration-is-clamped-to-zero.json b/types/test-cases/reducers/240-turn-duration-is-clamped-to-zero.json new file mode 100644 index 00000000..dbe67810 --- /dev/null +++ b/types/test-cases/reducers/240-turn-duration-is-clamped-to-zero.json @@ -0,0 +1,54 @@ +{ + "description": "chat/turnComplete clamps a negative turn duration to zero", + "reducer": "chat", + "initial": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:10.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:10.000Z" + }, + "actions": [ + { + "type": "chat/turnComplete", + "turnId": "turn-1", + "endedAt": "1970-01-01T00:00:09.000Z" + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:10.000Z", + "duration": 0, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete", + "error": null + } + ], + "activeTurn": null, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:09.000Z" + } +} From bcd3fdd22bdd94441b0488dabdcdc3ad87802853 Mon Sep 17 00:00:00 2001 From: justschen Date: Thu, 9 Jul 2026 21:38:23 -0700 Subject: [PATCH 2/3] swift: fix turn duration rounding Normalize timestamp fractions to whole milliseconds before converting through Foundation so floating-point representation cannot undercount elapsed turn durations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Sources/AgentHostProtocol/Reducers.swift | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index f01364a5..b1eb3c43 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -28,8 +28,21 @@ private func parseTimestamp(_ value: String) -> Date? { ?? iso8601TimestampFormatterWithoutFractionalSeconds.date(from: value) } -private func timestampMilliseconds(_ date: Date) -> Int { - Int(floor(date.timeIntervalSince1970 * 1000)) +private func parseTimestampMilliseconds(_ value: String) -> Int? { + guard parseTimestamp(value) != nil else { return nil } + + var normalized = value + if let decimal = value.firstIndex(of: "."), + let timezone = value[decimal...].firstIndex(where: { $0 == "Z" || $0 == "+" || $0 == "-" }) { + let fractionStart = value.index(after: decimal) + let fraction = value[fractionStart.. ChatState { // ── Turn Lifecycle ──────────────────────────────────────────────────── case .chatTurnStarted(let a): - guard parseTimestamp(a.startedAt) != nil else { return state } + guard parseTimestampMilliseconds(a.startedAt) != nil else { return state } var next = state next.modifiedAt = a.startedAt next.activeTurn = ActiveTurn( @@ -909,7 +922,7 @@ private func endTurn( terminalStatus: SessionStatus? = nil, error: ErrorInfo? = nil ) -> ChatState { - guard let ended = parseTimestamp(endedAt) else { return state } + guard let endedMilliseconds = parseTimestampMilliseconds(endedAt) else { return state } guard let activeTurn = state.activeTurn, activeTurn.id == turnId else { return state } @@ -959,9 +972,8 @@ private func endTurn( } } - let endedMilliseconds = timestampMilliseconds(ended) - let duration = parseTimestamp(activeTurn.startedAt) - .map { max(0, endedMilliseconds - timestampMilliseconds($0)) } + let duration = parseTimestampMilliseconds(activeTurn.startedAt) + .map { max(0, endedMilliseconds - $0) } let turn = Turn( id: activeTurn.id, startedAt: activeTurn.startedAt, From af2a6d3468b3e62dc5e37958af2acfa07f362b64 Mon Sep 17 00:00:00 2001 From: justschen Date: Fri, 10 Jul 2026 11:42:37 -0700 Subject: [PATCH 3/3] chat: use producer-measured turn duration Replace terminal action timestamps with elapsed durations measured by the action producer. This avoids cross-clock subtraction in reducers, removes timestamp parsing and the Rust chrono dependency, and keeps modifiedAt on a consistent reducer-local clock. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahp/reducers.go | 32 +++----- clients/go/ahptypes/actions.generated.go | 21 ++++-- clients/go/examples/reducers_demo/main.go | 6 +- .../microsoft/agenthostprotocol/Reducers.kt | 74 ++++++++----------- .../generated/Actions.generated.kt | 21 ++++-- .../agenthostprotocol/ReducersTest.kt | 5 +- clients/rust/Cargo.lock | 25 ------- clients/rust/Cargo.toml | 1 - clients/rust/crates/ahp-types/src/actions.rs | 21 ++++-- clients/rust/crates/ahp/Cargo.toml | 1 - clients/rust/crates/ahp/src/reducers.rs | 37 +++------- .../Generated/Actions.generated.swift | 39 ++++++---- .../Sources/AgentHostProtocol/Reducers.swift | 48 +++--------- .../ReducersTests.swift | 7 +- docs/.changes/20260709-turn-timing.json | 2 +- schema/actions.schema.json | 24 +++--- schema/commands.schema.json | 24 +++--- schema/errors.schema.json | 24 +++--- types/channels-chat/actions.ts | 27 +++++-- types/channels-chat/reducer.ts | 67 +++-------------- .../reducers/005-session-turnstarted.json | 2 +- ...messageid-removes-from-queuedmessages.json | 2 +- ...messageid-removes-last-queued-message.json | 2 +- ...eid-removes-matching-steering-message.json | 2 +- ...ageid-does-not-touch-pending-messages.json | 2 +- ...4-session-turncomplete-finalizes-turn.json | 2 +- ...-session-turncancelled-finalizes-turn.json | 2 +- ...ssion-error-finalizes-turn-with-error.json | 2 +- ...-force-cancels-in-progress-tool-calls.json | 2 +- ...rncomplete-with-wrong-turnid-is-no-op.json | 2 +- ...w-with-tool-calls-and-re-confirmation.json | 2 +- .../075-turnstarted-clears-isread.json | 2 +- ...dturn-force-cancels-running-tool-call.json | 2 +- ...nput-turn-end-cleans-turn-scoped-only.json | 2 +- .../161-chat-turn-lifecycle-on-chat.json | 2 +- ...invalid-turn-start-timestamp-is-no-op.json | 31 -------- ...9-invalid-turn-end-timestamp-is-no-op.json | 49 ------------ .../240-turn-duration-is-clamped-to-zero.json | 4 +- 38 files changed, 228 insertions(+), 392 deletions(-) delete mode 100644 types/test-cases/reducers/238-invalid-turn-start-timestamp-is-no-op.json delete mode 100644 types/test-cases/reducers/239-invalid-turn-end-timestamp-is-no-op.json diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index c3905761..a498d4c5 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -181,11 +181,7 @@ func touchChatModified(state *ahptypes.ChatState) { // ─── Active-turn helpers ─────────────────────────────────────────────── -func endTurn(state *ahptypes.ChatState, turnID, endedAt string, turnState ahptypes.TurnState, terminalStatus *ahptypes.SessionStatus, errInfo *ahptypes.ErrorInfo) ReduceOutcome { - ended, err := time.Parse(time.RFC3339Nano, endedAt) - if err != nil { - return ReduceOutcomeNoOp - } +func endTurn(state *ahptypes.ChatState, turnID string, duration int64, turnState ahptypes.TurnState, terminalStatus *ahptypes.SessionStatus, errInfo *ahptypes.ErrorInfo) ReduceOutcome { if state.ActiveTurn == nil || state.ActiveTurn.Id != turnID { return ReduceOutcomeNoOp } @@ -224,19 +220,16 @@ func endTurn(state *ahptypes.ChatState, turnID, endedAt string, turnState ahptyp }}) } - var duration *int64 - if started, err := time.Parse(time.RFC3339Nano, active.StartedAt); err == nil { - ms := ended.UnixMilli() - started.UnixMilli() - if ms < 0 { - ms = 0 - } - duration = &ms + // Defensive clamp: duration is producer-supplied and opaque to this + // reducer, but a negative value would be nonsensical to display. + if duration < 0 { + duration = 0 } startedAt := active.StartedAt turn := ahptypes.Turn{ Id: active.Id, StartedAt: &startedAt, - Duration: duration, + Duration: &duration, Message: active.Message, ResponseParts: parts, Usage: active.Usage, @@ -246,7 +239,7 @@ func endTurn(state *ahptypes.ChatState, turnID, endedAt string, turnState ahptyp state.Turns = append(state.Turns, turn) state.InputRequests = nil - state.ModifiedAt = endedAt + state.ModifiedAt = nowISOString() state.Status = summaryStatus(state, terminalStatus) return ReduceOutcomeApplied } @@ -479,13 +472,13 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R state.ActiveTurn.ResponseParts = append(state.ActiveTurn.ResponseParts, a.Part) return ReduceOutcomeApplied case *ahptypes.ChatTurnCompleteAction: - return endTurn(state, a.TurnId, a.EndedAt, ahptypes.TurnStateComplete, nil, nil) + return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateComplete, nil, nil) case *ahptypes.ChatTurnCancelledAction: - return endTurn(state, a.TurnId, a.EndedAt, ahptypes.TurnStateCancelled, nil, nil) + return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateCancelled, nil, nil) case *ahptypes.ChatErrorAction: errCopy := a.Error errStatus := ahptypes.SessionStatusError - return endTurn(state, a.TurnId, a.EndedAt, ahptypes.TurnStateError, &errStatus, &errCopy) + return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateError, &errStatus, &errCopy) case *ahptypes.ChatActivityChangedAction: state.Activity = a.Activity return ReduceOutcomeApplied @@ -937,9 +930,6 @@ func ApplyActionToSession(state *ahptypes.SessionState, action ahptypes.StateAct } func applyTurnStarted(state *ahptypes.ChatState, a *ahptypes.ChatTurnStartedAction) ReduceOutcome { - if _, err := time.Parse(time.RFC3339Nano, a.StartedAt); err != nil { - return ReduceOutcomeNoOp - } state.ActiveTurn = &ahptypes.ActiveTurn{ Id: a.TurnId, StartedAt: a.StartedAt, @@ -947,7 +937,7 @@ func applyTurnStarted(state *ahptypes.ChatState, a *ahptypes.ChatTurnStartedActi ResponseParts: []ahptypes.ResponsePart{}, } state.Status = summaryStatus(state, nil) - state.ModifiedAt = a.StartedAt + state.ModifiedAt = nowISOString() state.Status = withStatusFlag(state.Status, ahptypes.SessionStatusIsRead, false) if a.QueuedMessageId != nil { diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index ca866fb1..495c9c3c 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -462,8 +462,11 @@ type ChatTurnCompleteAction struct { Type ActionType `json:"type"` // Turn identifier TurnId string `json:"turnId"` - // ISO 8601 timestamp when this turn completed. - EndedAt string `json:"endedAt"` + // Elapsed turn duration in milliseconds, measured by the producer's own + // clock. Clients MUST NOT derive this by subtracting timestamps — cross- + // client clocks may differ — and MUST treat it as opaque, producer-supplied + // data. + Duration int64 `json:"duration"` // Additional provider-specific metadata for this action. // // Clients MAY look for well-known keys here to provide enhanced UI, and @@ -479,8 +482,11 @@ type ChatTurnCancelledAction struct { Type ActionType `json:"type"` // Turn identifier TurnId string `json:"turnId"` - // ISO 8601 timestamp when this turn was cancelled. - EndedAt string `json:"endedAt"` + // Elapsed turn duration in milliseconds, measured by the producer's own + // clock. Clients MUST NOT derive this by subtracting timestamps — cross- + // client clocks may differ — and MUST treat it as opaque, producer-supplied + // data. + Duration int64 `json:"duration"` // Additional provider-specific metadata for this action. // // Clients MAY look for well-known keys here to provide enhanced UI, and @@ -496,8 +502,11 @@ type ChatErrorAction struct { Type ActionType `json:"type"` // Turn identifier TurnId string `json:"turnId"` - // ISO 8601 timestamp when this turn failed. - EndedAt string `json:"endedAt"` + // Elapsed turn duration in milliseconds, measured by the producer's own + // clock. Clients MUST NOT derive this by subtracting timestamps — cross- + // client clocks may differ — and MUST treat it as opaque, producer-supplied + // data. + Duration int64 `json:"duration"` // Error details Error ErrorInfo `json:"error"` // Additional provider-specific metadata for this action. diff --git a/clients/go/examples/reducers_demo/main.go b/clients/go/examples/reducers_demo/main.go index 69c7b4e3..139d8c63 100644 --- a/clients/go/examples/reducers_demo/main.go +++ b/clients/go/examples/reducers_demo/main.go @@ -41,9 +41,9 @@ func main() { Content: "there!", }}, {Value: &ahptypes.ChatTurnCompleteAction{ - Type: ahptypes.ActionTypeChatTurnComplete, - TurnId: "t1", - EndedAt: "2026-07-09T20:00:01.000Z", + Type: ahptypes.ActionTypeChatTurnComplete, + TurnId: "t1", + Duration: 1000, }}, } 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 049277f5..dbe1f83c 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -82,11 +82,6 @@ public var currentTimestampProvider: () -> Long = { System.currentTimeMillis() } private fun nowIsoString(): String = Instant.ofEpochMilli(currentTimestampProvider()).toString() -private fun parseTimestampMillis(value: String): Long? { - val millis = runCatching { Instant.parse(value).toEpochMilli() }.getOrNull() ?: return null - return millis.takeIf { it in -8_640_000_000_000_000L..8_640_000_000_000_000L } -} - // ─── Status Bitset Helpers ────────────────────────────────────────────────── /** Bitmask covering the mutually-exclusive activity bits (bits 0–4). */ @@ -337,12 +332,11 @@ private fun updateResponsePart( private fun endTurn( state: ChatState, turnId: String, - endedAt: String, + duration: Long, turnState: TurnState, terminalStatus: SessionStatus? = null, error: ErrorInfo? = null, ): ChatState { - val ended = parseTimestampMillis(endedAt) ?: return state val active = state.activeTurn ?: return state if (active.id != turnId) return state @@ -393,12 +387,12 @@ private fun endTurn( ) } - val duration = parseTimestampMillis(active.startedAt) - ?.let { started -> maxOf(0L, ended - started) } + // Defensive clamp: `duration` is producer-supplied and opaque to this + // reducer, but a negative value would be nonsensical to display. val turn = Turn( id = active.id, startedAt = active.startedAt, - duration = duration, + duration = maxOf(0L, duration), message = active.message, responseParts = finalizedParts, usage = active.usage, @@ -410,7 +404,7 @@ private fun endTurn( turns = state.turns + turn, activeTurn = null, inputRequests = null, - modifiedAt = endedAt, + modifiedAt = nowIsoString(), ) return withoutTurn.copy(status = chatSummaryStatus(withoutTurn, terminalStatus)) } @@ -755,36 +749,32 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when is StateActionChatTurnStarted -> { val a = action.value - if (parseTimestampMillis(a.startedAt) == null) { - state + val withTurn = state.copy( + activeTurn = ActiveTurn( + id = a.turnId, + startedAt = a.startedAt, + message = a.message, + responseParts = emptyList(), + usage = null, + ), + ) + val withStatus = withTurn.copy( + status = withStatusFlag(chatSummaryStatus(withTurn), SessionStatus.IS_READ, false), + modifiedAt = nowIsoString(), + ) + if (a.queuedMessageId == null) { + withStatus } else { - val withTurn = state.copy( - activeTurn = ActiveTurn( - id = a.turnId, - startedAt = a.startedAt, - message = a.message, - responseParts = emptyList(), - usage = null, - ), - ) - val withStatus = withTurn.copy( - status = withStatusFlag(chatSummaryStatus(withTurn), SessionStatus.IS_READ, false), - modifiedAt = a.startedAt, - ) - if (a.queuedMessageId == null) { - withStatus - } else { - var next = withStatus - if (next.steeringMessage?.id == a.queuedMessageId) { - next = next.copy(steeringMessage = null) - } - val queued = next.queuedMessages - if (queued != null) { - val filtered = queued.filter { it.id != a.queuedMessageId } - next = next.copy(queuedMessages = filtered.ifEmpty { null }) - } - next + var next = withStatus + if (next.steeringMessage?.id == a.queuedMessageId) { + next = next.copy(steeringMessage = null) + } + val queued = next.queuedMessages + if (queued != null) { + val filtered = queued.filter { it.id != a.queuedMessageId } + next = next.copy(queuedMessages = filtered.ifEmpty { null }) } + next } } @@ -812,13 +802,13 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when } is StateActionChatTurnComplete -> - endTurn(state, action.value.turnId, action.value.endedAt, TurnState.COMPLETE) + endTurn(state, action.value.turnId, action.value.duration, TurnState.COMPLETE) is StateActionChatTurnCancelled -> - endTurn(state, action.value.turnId, action.value.endedAt, TurnState.CANCELLED) + endTurn(state, action.value.turnId, action.value.duration, TurnState.CANCELLED) is StateActionChatError -> - endTurn(state, action.value.turnId, action.value.endedAt, TurnState.ERROR, SessionStatus.ERROR, action.value.error) + endTurn(state, action.value.turnId, action.value.duration, TurnState.ERROR, SessionStatus.ERROR, action.value.error) is StateActionChatActivityChanged -> state.copy(activity = action.value.activity) 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 657d9280..46101893 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 @@ -614,9 +614,12 @@ data class ChatTurnCompleteAction( */ val turnId: String, /** - * ISO 8601 timestamp when this turn completed. + * Elapsed turn duration in milliseconds, measured by the producer's own + * clock. Clients MUST NOT derive this by subtracting timestamps — cross- + * client clocks may differ — and MUST treat it as opaque, producer-supplied + * data. */ - val endedAt: String, + val duration: Long, /** * Additional provider-specific metadata for this action. * @@ -638,9 +641,12 @@ data class ChatTurnCancelledAction( */ val turnId: String, /** - * ISO 8601 timestamp when this turn was cancelled. + * Elapsed turn duration in milliseconds, measured by the producer's own + * clock. Clients MUST NOT derive this by subtracting timestamps — cross- + * client clocks may differ — and MUST treat it as opaque, producer-supplied + * data. */ - val endedAt: String, + val duration: Long, /** * Additional provider-specific metadata for this action. * @@ -662,9 +668,12 @@ data class ChatErrorAction( */ val turnId: String, /** - * ISO 8601 timestamp when this turn failed. + * Elapsed turn duration in milliseconds, measured by the producer's own + * clock. Clients MUST NOT derive this by subtracting timestamps — cross- + * client clocks may differ — and MUST treat it as opaque, producer-supplied + * data. */ - val endedAt: String, + val duration: Long, /** * Error details */ diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt index 111a693e..633193b6 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt @@ -209,7 +209,7 @@ class ReducersTest { } @Test - fun `turn start timestamp flows through to reducer outputs`() { + fun `turn start stores producer timestamp while modifiedAt uses local clock`() { val chatResult = chatReducer( newChat(), StateActionChatTurnStarted( @@ -221,7 +221,8 @@ class ReducersTest { ), ), ) - assertEquals("1970-01-01T00:00:12.345Z", chatResult.modifiedAt) + assertEquals("1970-01-01T00:00:09.999Z", chatResult.modifiedAt) + assertEquals("1970-01-01T00:00:12.345Z", chatResult.activeTurn?.startedAt) } @Test diff --git a/clients/rust/Cargo.lock b/clients/rust/Cargo.lock index 7d8aaa1c..b3a0f2ab 100644 --- a/clients/rust/Cargo.lock +++ b/clients/rust/Cargo.lock @@ -8,7 +8,6 @@ version = "0.5.2" dependencies = [ "ahp-types", "ahp-ws", - "chrono", "serde", "serde_json", "thiserror", @@ -46,12 +45,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - [[package]] name = "bitflags" version = "2.11.1" @@ -89,15 +82,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "num-traits", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -559,15 +543,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "once_cell" version = "1.21.4" diff --git a/clients/rust/Cargo.toml b/clients/rust/Cargo.toml index 1fdd4ba6..9c23c7b4 100644 --- a/clients/rust/Cargo.toml +++ b/clients/rust/Cargo.toml @@ -22,6 +22,5 @@ tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } tokio-util = "0.7" futures-util = "0.3" tracing = "0.1" -chrono = { version = "0.4", default-features = false, features = ["std"] } ahp-types = { path = "crates/ahp-types", version = "0.5.2" } ahp = { path = "crates/ahp", version = "0.5.2" } diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 605ac97a..cd5b3e25 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -615,8 +615,11 @@ pub struct ChatToolCallContentChangedAction { pub struct ChatTurnCompleteAction { /// Turn identifier pub turn_id: String, - /// ISO 8601 timestamp when this turn completed. - pub ended_at: String, + /// Elapsed turn duration in milliseconds, measured by the producer's own + /// clock. Clients MUST NOT derive this by subtracting timestamps — cross- + /// client clocks may differ — and MUST treat it as opaque, producer-supplied + /// data. + pub duration: i64, /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -634,8 +637,11 @@ pub struct ChatTurnCompleteAction { pub struct ChatTurnCancelledAction { /// Turn identifier pub turn_id: String, - /// ISO 8601 timestamp when this turn was cancelled. - pub ended_at: String, + /// Elapsed turn duration in milliseconds, measured by the producer's own + /// clock. Clients MUST NOT derive this by subtracting timestamps — cross- + /// client clocks may differ — and MUST treat it as opaque, producer-supplied + /// data. + pub duration: i64, /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -653,8 +659,11 @@ pub struct ChatTurnCancelledAction { pub struct ChatErrorAction { /// Turn identifier pub turn_id: String, - /// ISO 8601 timestamp when this turn failed. - pub ended_at: String, + /// Elapsed turn duration in milliseconds, measured by the producer's own + /// clock. Clients MUST NOT derive this by subtracting timestamps — cross- + /// client clocks may differ — and MUST treat it as opaque, producer-supplied + /// data. + pub duration: i64, /// Error details pub error: ErrorInfo, /// Additional provider-specific metadata for this action. diff --git a/clients/rust/crates/ahp/Cargo.toml b/clients/rust/crates/ahp/Cargo.toml index 86aefd8e..4608dc74 100644 --- a/clients/rust/crates/ahp/Cargo.toml +++ b/clients/rust/crates/ahp/Cargo.toml @@ -23,7 +23,6 @@ serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } -chrono = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 42a59a9d..fdf36a9a 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -68,7 +68,6 @@ use ahp_types::state::{ ToolCallResponsePart, ToolCallRunningState, ToolCallState, ToolCallStreamingState, Turn, TurnState, }; -use chrono::{DateTime, FixedOffset}; /// What happened when an action was applied. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -276,21 +275,14 @@ fn touch_chat_modified(state: &mut ChatState) { state.modified_at = now_iso(); } -fn parse_timestamp(value: &str) -> Option> { - DateTime::parse_from_rfc3339(value).ok() -} - fn end_turn( state: &mut ChatState, turn_id: &str, - ended_at: &str, + duration: i64, turn_state: TurnState, terminal_status: Option, error: Option, ) -> ReduceOutcome { - let Some(ended) = parse_timestamp(ended_at) else { - return ReduceOutcome::NoOp; - }; let Some(active) = state.active_turn.as_ref() else { return ReduceOutcome::NoOp; }; @@ -353,16 +345,12 @@ fn end_turn( }) .collect(); - let duration = parse_timestamp(&active.started_at).and_then(|started| { - ended - .timestamp_millis() - .checked_sub(started.timestamp_millis()) - .map(|duration| duration.max(0)) - }); + // Defensive clamp: `duration` is producer-supplied and opaque to this + // reducer, but a negative value would be nonsensical to display. let turn = Turn { id: active.id, started_at: Some(active.started_at), - duration, + duration: Some(duration.max(0)), message: active.message, response_parts, usage: active.usage, @@ -372,7 +360,7 @@ fn end_turn( state.turns.push(turn); state.input_requests = None; - state.modified_at = ended_at.to_owned(); + state.modified_at = now_iso(); state.status = summary_status(state, terminal_status); ReduceOutcome::Applied } @@ -894,7 +882,7 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu StateAction::ChatTurnComplete(a) => end_turn( state, &a.turn_id, - &a.ended_at, + a.duration, TurnState::Complete, None, None, @@ -902,7 +890,7 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu StateAction::ChatTurnCancelled(a) => end_turn( state, &a.turn_id, - &a.ended_at, + a.duration, TurnState::Cancelled, None, None, @@ -910,7 +898,7 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu StateAction::ChatError(a) => end_turn( state, &a.turn_id, - &a.ended_at, + a.duration, TurnState::Error, Some(SessionStatus::Error), Some(a.error.clone()), @@ -1123,9 +1111,6 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu } fn apply_turn_started(state: &mut ChatState, a: &ChatTurnStartedAction) -> ReduceOutcome { - if parse_timestamp(&a.started_at).is_none() { - return ReduceOutcome::NoOp; - } state.active_turn = Some(ActiveTurn { id: a.turn_id.clone(), started_at: a.started_at.clone(), @@ -1134,7 +1119,7 @@ fn apply_turn_started(state: &mut ChatState, a: &ChatTurnStartedAction) -> Reduc usage: None, }); state.status = summary_status(state, None); - state.modified_at = a.started_at.clone(); + touch_chat_modified(state); state.status = with_status_flag(state.status, SessionStatus::IsRead, false); if let Some(qmid) = &a.queued_message_id { @@ -1851,7 +1836,7 @@ mod tests { s.status = SessionStatus::InProgress.bits(); let a = StateAction::ChatTurnComplete(ahp_types::actions::ChatTurnCompleteAction { turn_id: "t1".into(), - ended_at: "2026-07-09T20:00:01.000Z".into(), + duration: 1000, meta: None, }); assert_eq!(apply_action_to_chat(&mut s, &a), ReduceOutcome::Applied); @@ -1921,7 +1906,7 @@ mod tests { // A chat-scoped action is out of scope for the session reducer. let turn = StateAction::ChatTurnComplete(ahp_types::actions::ChatTurnCompleteAction { turn_id: "t1".into(), - ended_at: "2026-07-09T20:00:01.000Z".into(), + duration: 1000, meta: None, }); assert_eq!( diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 516f7a97..be9c894b 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -716,8 +716,11 @@ public struct ChatTurnCompleteAction: Codable, Sendable { public var type: ActionType /// Turn identifier public var turnId: String - /// ISO 8601 timestamp when this turn completed. - public var endedAt: String + /// Elapsed turn duration in milliseconds, measured by the producer's own + /// clock. Clients MUST NOT derive this by subtracting timestamps — cross- + /// client clocks may differ — and MUST treat it as opaque, producer-supplied + /// data. + public var duration: Int /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -730,19 +733,19 @@ public struct ChatTurnCompleteAction: Codable, Sendable { enum CodingKeys: String, CodingKey { case type case turnId - case endedAt + case duration case meta = "_meta" } public init( type: ActionType, turnId: String, - endedAt: String, + duration: Int, meta: [String: AnyCodable]? = nil ) { self.type = type self.turnId = turnId - self.endedAt = endedAt + self.duration = duration self.meta = meta } } @@ -751,8 +754,11 @@ public struct ChatTurnCancelledAction: Codable, Sendable { public var type: ActionType /// Turn identifier public var turnId: String - /// ISO 8601 timestamp when this turn was cancelled. - public var endedAt: String + /// Elapsed turn duration in milliseconds, measured by the producer's own + /// clock. Clients MUST NOT derive this by subtracting timestamps — cross- + /// client clocks may differ — and MUST treat it as opaque, producer-supplied + /// data. + public var duration: Int /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -765,19 +771,19 @@ public struct ChatTurnCancelledAction: Codable, Sendable { enum CodingKeys: String, CodingKey { case type case turnId - case endedAt + case duration case meta = "_meta" } public init( type: ActionType, turnId: String, - endedAt: String, + duration: Int, meta: [String: AnyCodable]? = nil ) { self.type = type self.turnId = turnId - self.endedAt = endedAt + self.duration = duration self.meta = meta } } @@ -786,8 +792,11 @@ public struct ChatErrorAction: Codable, Sendable { public var type: ActionType /// Turn identifier public var turnId: String - /// ISO 8601 timestamp when this turn failed. - public var endedAt: String + /// Elapsed turn duration in milliseconds, measured by the producer's own + /// clock. Clients MUST NOT derive this by subtracting timestamps — cross- + /// client clocks may differ — and MUST treat it as opaque, producer-supplied + /// data. + public var duration: Int /// Error details public var error: ErrorInfo /// Additional provider-specific metadata for this action. @@ -802,7 +811,7 @@ public struct ChatErrorAction: Codable, Sendable { enum CodingKeys: String, CodingKey { case type case turnId - case endedAt + case duration case error case meta = "_meta" } @@ -810,13 +819,13 @@ public struct ChatErrorAction: Codable, Sendable { public init( type: ActionType, turnId: String, - endedAt: String, + duration: Int, error: ErrorInfo, meta: [String: AnyCodable]? = nil ) { self.type = type self.turnId = turnId - self.endedAt = endedAt + self.duration = duration self.error = error self.meta = meta } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index b1eb3c43..bc7a4028 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -17,34 +17,6 @@ private let iso8601TimestampFormatter: ISO8601DateFormatter = { return formatter }() -private let iso8601TimestampFormatterWithoutFractionalSeconds: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - return formatter -}() - -private func parseTimestamp(_ value: String) -> Date? { - iso8601TimestampFormatter.date(from: value) - ?? iso8601TimestampFormatterWithoutFractionalSeconds.date(from: value) -} - -private func parseTimestampMilliseconds(_ value: String) -> Int? { - guard parseTimestamp(value) != nil else { return nil } - - var normalized = value - if let decimal = value.firstIndex(of: "."), - let timezone = value[decimal...].firstIndex(where: { $0 == "Z" || $0 == "+" || $0 == "-" }) { - let fractionStart = value.index(after: decimal) - let fraction = value[fractionStart.. ChatState { // ── Turn Lifecycle ──────────────────────────────────────────────────── case .chatTurnStarted(let a): - guard parseTimestampMilliseconds(a.startedAt) != nil else { return state } var next = state - next.modifiedAt = a.startedAt + next.modifiedAt = currentTimestamp() next.activeTurn = ActiveTurn( id: a.turnId, startedAt: a.startedAt, @@ -168,13 +139,13 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { return next case .chatTurnComplete(let a): - return endTurn(state: state, turnId: a.turnId, endedAt: a.endedAt, turnState: .complete) + return endTurn(state: state, turnId: a.turnId, duration: a.duration, turnState: .complete) case .chatTurnCancelled(let a): - return endTurn(state: state, turnId: a.turnId, endedAt: a.endedAt, turnState: .cancelled) + return endTurn(state: state, turnId: a.turnId, duration: a.duration, turnState: .cancelled) case .chatError(let a): - return endTurn(state: state, turnId: a.turnId, endedAt: a.endedAt, turnState: .error, terminalStatus: .error, error: a.error) + return endTurn(state: state, turnId: a.turnId, duration: a.duration, turnState: .error, terminalStatus: .error, error: a.error) case .chatActivityChanged(let a): var next = state @@ -917,12 +888,11 @@ private func upsertInputRequest(state: ChatState, request: ChatInputRequest) -> private func endTurn( state: ChatState, turnId: String, - endedAt: String, + duration: Int, turnState: TurnState, terminalStatus: SessionStatus? = nil, error: ErrorInfo? = nil ) -> ChatState { - guard let endedMilliseconds = parseTimestampMilliseconds(endedAt) else { return state } guard let activeTurn = state.activeTurn, activeTurn.id == turnId else { return state } @@ -972,12 +942,12 @@ private func endTurn( } } - let duration = parseTimestampMilliseconds(activeTurn.startedAt) - .map { max(0, endedMilliseconds - $0) } + // Defensive clamp: `duration` is producer-supplied and opaque to this + // reducer, but a negative value would be nonsensical to display. let turn = Turn( id: activeTurn.id, startedAt: activeTurn.startedAt, - duration: duration, + duration: max(0, duration), message: activeTurn.message, responseParts: responseParts, usage: activeTurn.usage, @@ -990,7 +960,7 @@ private func endTurn( next.activeTurn = nil next.inputRequests = nil next.status = chatSummaryStatus(next, terminalStatus: terminalStatus) - next.modifiedAt = endedAt + next.modifiedAt = currentTimestamp() return next } diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift index 4d910ee2..b234db36 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift @@ -103,6 +103,10 @@ final class ReducersTests: XCTestCase { // MARK: - Timestamp Behavior func testChatTurnStartedUpdatesModifiedAt() { + let originalProvider = currentTimestampProvider + currentTimestampProvider = { 9_999 } + defer { currentTimestampProvider = originalProvider } + let state = ChatState( resource: C, title: "Test Chat", @@ -117,7 +121,8 @@ final class ReducersTests: XCTestCase { message: Message(text: "Hello", origin: MessageOrigin(kind: .user)) )) ) - XCTAssertEqual(next.modifiedAt, "2026-07-09T20:00:00.000Z") + XCTAssertEqual(next.modifiedAt, "1970-01-01T00:00:09.999Z") + XCTAssertEqual(next.activeTurn?.startedAt, "2026-07-09T20:00:00.000Z") } func testTitleChangedUpdatesTitle() { diff --git a/docs/.changes/20260709-turn-timing.json b/docs/.changes/20260709-turn-timing.json index 43d248e7..e8e37dea 100644 --- a/docs/.changes/20260709-turn-timing.json +++ b/docs/.changes/20260709-turn-timing.json @@ -1,4 +1,4 @@ { "type": "added", - "message": "Turn lifecycle actions now carry timestamps, and completed turns expose their start time and duration." + "message": "`chat/turnStarted` carries a `startedAt` timestamp, `chat/turnComplete`/`chat/turnCancelled`/`chat/error` carry an elapsed `duration` (milliseconds, producer's own clock), and completed turns expose their start time and duration." } diff --git a/schema/actions.schema.json b/schema/actions.schema.json index d8e84ef1..26162e44 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -1094,9 +1094,9 @@ "type": "string", "description": "Turn identifier" }, - "endedAt": { - "type": "string", - "description": "ISO 8601 timestamp when this turn completed." + "duration": { + "type": "number", + "description": "Elapsed turn duration in milliseconds, measured by the producer's own\nclock. Clients MUST NOT derive this by subtracting timestamps — cross-\nclient clocks may differ — and MUST treat it as opaque, producer-supplied\ndata." }, "_meta": { "type": "object", @@ -1107,7 +1107,7 @@ "required": [ "type", "turnId", - "endedAt" + "duration" ] }, "ChatTurnCancelledAction": { @@ -1121,9 +1121,9 @@ "type": "string", "description": "Turn identifier" }, - "endedAt": { - "type": "string", - "description": "ISO 8601 timestamp when this turn was cancelled." + "duration": { + "type": "number", + "description": "Elapsed turn duration in milliseconds, measured by the producer's own\nclock. Clients MUST NOT derive this by subtracting timestamps — cross-\nclient clocks may differ — and MUST treat it as opaque, producer-supplied\ndata." }, "_meta": { "type": "object", @@ -1134,7 +1134,7 @@ "required": [ "type", "turnId", - "endedAt" + "duration" ] }, "ChatErrorAction": { @@ -1148,9 +1148,9 @@ "type": "string", "description": "Turn identifier" }, - "endedAt": { - "type": "string", - "description": "ISO 8601 timestamp when this turn failed." + "duration": { + "type": "number", + "description": "Elapsed turn duration in milliseconds, measured by the producer's own\nclock. Clients MUST NOT derive this by subtracting timestamps — cross-\nclient clocks may differ — and MUST treat it as opaque, producer-supplied\ndata." }, "error": { "$ref": "#/$defs/ErrorInfo", @@ -1165,7 +1165,7 @@ "required": [ "type", "turnId", - "endedAt", + "duration", "error" ] }, diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 4eeeb18f..838c6cf5 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -6940,9 +6940,9 @@ "type": "string", "description": "Turn identifier" }, - "endedAt": { - "type": "string", - "description": "ISO 8601 timestamp when this turn completed." + "duration": { + "type": "number", + "description": "Elapsed turn duration in milliseconds, measured by the producer's own\nclock. Clients MUST NOT derive this by subtracting timestamps — cross-\nclient clocks may differ — and MUST treat it as opaque, producer-supplied\ndata." }, "_meta": { "type": "object", @@ -6953,7 +6953,7 @@ "required": [ "type", "turnId", - "endedAt" + "duration" ] }, "ChatTurnCancelledAction": { @@ -6967,9 +6967,9 @@ "type": "string", "description": "Turn identifier" }, - "endedAt": { - "type": "string", - "description": "ISO 8601 timestamp when this turn was cancelled." + "duration": { + "type": "number", + "description": "Elapsed turn duration in milliseconds, measured by the producer's own\nclock. Clients MUST NOT derive this by subtracting timestamps — cross-\nclient clocks may differ — and MUST treat it as opaque, producer-supplied\ndata." }, "_meta": { "type": "object", @@ -6980,7 +6980,7 @@ "required": [ "type", "turnId", - "endedAt" + "duration" ] }, "ChatErrorAction": { @@ -6994,9 +6994,9 @@ "type": "string", "description": "Turn identifier" }, - "endedAt": { - "type": "string", - "description": "ISO 8601 timestamp when this turn failed." + "duration": { + "type": "number", + "description": "Elapsed turn duration in milliseconds, measured by the producer's own\nclock. Clients MUST NOT derive this by subtracting timestamps — cross-\nclient clocks may differ — and MUST treat it as opaque, producer-supplied\ndata." }, "error": { "$ref": "#/$defs/ErrorInfo", @@ -7011,7 +7011,7 @@ "required": [ "type", "turnId", - "endedAt", + "duration", "error" ] }, diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 36f341e0..7491372f 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -7763,9 +7763,9 @@ "type": "string", "description": "Turn identifier" }, - "endedAt": { - "type": "string", - "description": "ISO 8601 timestamp when this turn completed." + "duration": { + "type": "number", + "description": "Elapsed turn duration in milliseconds, measured by the producer's own\nclock. Clients MUST NOT derive this by subtracting timestamps — cross-\nclient clocks may differ — and MUST treat it as opaque, producer-supplied\ndata." }, "_meta": { "type": "object", @@ -7776,7 +7776,7 @@ "required": [ "type", "turnId", - "endedAt" + "duration" ] }, "ChatTurnCancelledAction": { @@ -7790,9 +7790,9 @@ "type": "string", "description": "Turn identifier" }, - "endedAt": { - "type": "string", - "description": "ISO 8601 timestamp when this turn was cancelled." + "duration": { + "type": "number", + "description": "Elapsed turn duration in milliseconds, measured by the producer's own\nclock. Clients MUST NOT derive this by subtracting timestamps — cross-\nclient clocks may differ — and MUST treat it as opaque, producer-supplied\ndata." }, "_meta": { "type": "object", @@ -7803,7 +7803,7 @@ "required": [ "type", "turnId", - "endedAt" + "duration" ] }, "ChatErrorAction": { @@ -7817,9 +7817,9 @@ "type": "string", "description": "Turn identifier" }, - "endedAt": { - "type": "string", - "description": "ISO 8601 timestamp when this turn failed." + "duration": { + "type": "number", + "description": "Elapsed turn duration in milliseconds, measured by the producer's own\nclock. Clients MUST NOT derive this by subtracting timestamps — cross-\nclient clocks may differ — and MUST treat it as opaque, producer-supplied\ndata." }, "error": { "$ref": "#/$defs/ErrorInfo", @@ -7834,7 +7834,7 @@ "required": [ "type", "turnId", - "endedAt", + "duration", "error" ] }, diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index 4ad13f5c..c1f5e663 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -345,8 +345,13 @@ export interface ChatTurnCompleteAction { type: ActionType.ChatTurnComplete; /** Turn identifier */ turnId: string; - /** ISO 8601 timestamp when this turn completed. */ - endedAt: string; + /** + * Elapsed turn duration in milliseconds, measured by the producer's own + * clock. Clients MUST NOT derive this by subtracting timestamps — cross- + * client clocks may differ — and MUST treat it as opaque, producer-supplied + * data. + */ + duration: number; /** * Additional provider-specific metadata for this action. * @@ -370,8 +375,13 @@ export interface ChatTurnCancelledAction { type: ActionType.ChatTurnCancelled; /** Turn identifier */ turnId: string; - /** ISO 8601 timestamp when this turn was cancelled. */ - endedAt: string; + /** + * Elapsed turn duration in milliseconds, measured by the producer's own + * clock. Clients MUST NOT derive this by subtracting timestamps — cross- + * client clocks may differ — and MUST treat it as opaque, producer-supplied + * data. + */ + duration: number; /** * Additional provider-specific metadata for this action. * @@ -394,8 +404,13 @@ export interface ChatErrorAction { type: ActionType.ChatError; /** Turn identifier */ turnId: string; - /** ISO 8601 timestamp when this turn failed. */ - endedAt: string; + /** + * Elapsed turn duration in milliseconds, measured by the producer's own + * clock. Clients MUST NOT derive this by subtracting timestamps — cross- + * client clocks may differ — and MUST treat it as opaque, producer-supplied + * data. + */ + duration: number; /** Error details */ error: ErrorInfo; /** diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index ab8dff9c..1da23f9a 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -117,7 +117,7 @@ function endTurn( state: ChatState, turnId: string, turnState: TurnState, - endedAt: string, + duration: number, terminalStatus?: SessionStatus.Error, error?: { errorType: string; message: string; stack?: string }, ): ChatState { @@ -147,15 +147,12 @@ function endTurn( }; }); - const startedAt = Date.parse(active.startedAt); - const completedAt = Date.parse(endedAt); - const duration = Number.isFinite(startedAt) && Number.isFinite(completedAt) - ? Math.max(0, completedAt - startedAt) - : undefined; const turn: Turn = { id: active.id, startedAt: active.startedAt, - duration, + // Defensive clamp: the duration is producer-supplied and opaque to this + // reducer, but a negative value would be nonsensical to display. + duration: Math.max(0, duration), message: active.message, responseParts, usage: active.usage, @@ -167,7 +164,7 @@ function endTurn( ...state, turns: [...state.turns, turn], activeTurn: undefined, - modifiedAt: endedAt, + modifiedAt: new Date(Date.now()).toISOString(), }; delete next.inputRequests; return { @@ -272,36 +269,6 @@ function updateResponsePart( // ─── Chat Reducer ──────────────────────────────────────────────────────────── -function isValidTimestamp(value: string): boolean { - const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-](\d{2}):(\d{2}))$/.exec(value); - if (!match) { - return false; - } - - const [, yearText, monthText, dayText, hourText, minuteText, secondText, offsetHourText, offsetMinuteText] = match; - const year = Number(yearText); - const month = Number(monthText); - const day = Number(dayText); - const hour = Number(hourText); - const minute = Number(minuteText); - const second = Number(secondText); - const offsetHour = offsetHourText === undefined ? 0 : Number(offsetHourText); - const offsetMinute = offsetMinuteText === undefined ? 0 : Number(offsetMinuteText); - const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); - const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - - return month >= 1 - && month <= 12 - && day >= 1 - && day <= daysInMonth[month - 1] - && hour <= 23 - && minute <= 59 - && second <= 59 - && offsetHour <= 23 - && offsetMinute <= 59 - && Number.isFinite(Date.parse(value)); -} - /** * Pure reducer for chat state. Handles all {@link ChatAction} variants. */ @@ -310,10 +277,6 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st // ── Turn Lifecycle ──────────────────────────────────────────────────── case ActionType.ChatTurnStarted: { - if (!isValidTimestamp(action.startedAt)) { - log?.(`Ignoring ChatTurnStarted with invalid startedAt: ${action.startedAt}`); - return state; - } let next: ChatState = { ...state, activeTurn: { @@ -327,7 +290,7 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st next = { ...next, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), - modifiedAt: action.startedAt, + modifiedAt: new Date(Date.now()).toISOString(), }; // If this turn was auto-started from a pending message, remove it @@ -365,25 +328,13 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st }; case ActionType.ChatTurnComplete: - if (!isValidTimestamp(action.endedAt)) { - log?.(`Ignoring ChatTurnComplete with invalid endedAt: ${action.endedAt}`); - return state; - } - return endTurn(state, action.turnId, TurnState.Complete, action.endedAt); + return endTurn(state, action.turnId, TurnState.Complete, action.duration); case ActionType.ChatTurnCancelled: - if (!isValidTimestamp(action.endedAt)) { - log?.(`Ignoring ChatTurnCancelled with invalid endedAt: ${action.endedAt}`); - return state; - } - return endTurn(state, action.turnId, TurnState.Cancelled, action.endedAt); + return endTurn(state, action.turnId, TurnState.Cancelled, action.duration); case ActionType.ChatError: - if (!isValidTimestamp(action.endedAt)) { - log?.(`Ignoring ChatError with invalid endedAt: ${action.endedAt}`); - return state; - } - return endTurn(state, action.turnId, TurnState.Error, action.endedAt, SessionStatus.Error, action.error); + return endTurn(state, action.turnId, TurnState.Error, action.duration, SessionStatus.Error, action.error); case ActionType.ChatActivityChanged: return { ...state, activity: action.activity }; diff --git a/types/test-cases/reducers/005-session-turnstarted.json b/types/test-cases/reducers/005-session-turnstarted.json index 0fb745ce..9b8300dd 100644 --- a/types/test-cases/reducers/005-session-turnstarted.json +++ b/types/test-cases/reducers/005-session-turnstarted.json @@ -38,6 +38,6 @@ "resource": "copilot:/test-session", "title": "Test Session", "status": 8, - "modifiedAt": "1970-01-01T00:00:01.000Z" + "modifiedAt": "1970-01-01T00:00:09.999Z" } } diff --git a/types/test-cases/reducers/006-turnstarted-with-queuedmessageid-removes-from-queuedmessages.json b/types/test-cases/reducers/006-turnstarted-with-queuedmessageid-removes-from-queuedmessages.json index 1977a91a..a63e36ad 100644 --- a/types/test-cases/reducers/006-turnstarted-with-queuedmessageid-removes-from-queuedmessages.json +++ b/types/test-cases/reducers/006-turnstarted-with-queuedmessageid-removes-from-queuedmessages.json @@ -70,6 +70,6 @@ "resource": "copilot:/test-session", "title": "Test Session", "status": 8, - "modifiedAt": "1970-01-01T00:00:01.000Z" + "modifiedAt": "1970-01-01T00:00:09.999Z" } } diff --git a/types/test-cases/reducers/007-turnstarted-with-queuedmessageid-removes-last-queued-message.json b/types/test-cases/reducers/007-turnstarted-with-queuedmessageid-removes-last-queued-message.json index faad3e50..8916d48d 100644 --- a/types/test-cases/reducers/007-turnstarted-with-queuedmessageid-removes-last-queued-message.json +++ b/types/test-cases/reducers/007-turnstarted-with-queuedmessageid-removes-last-queued-message.json @@ -51,6 +51,6 @@ "resource": "copilot:/test-session", "title": "Test Session", "status": 8, - "modifiedAt": "1970-01-01T00:00:01.000Z" + "modifiedAt": "1970-01-01T00:00:09.999Z" } } diff --git a/types/test-cases/reducers/008-turnstarted-with-queuedmessageid-removes-matching-steering-message.json b/types/test-cases/reducers/008-turnstarted-with-queuedmessageid-removes-matching-steering-message.json index 022860aa..23d591ef 100644 --- a/types/test-cases/reducers/008-turnstarted-with-queuedmessageid-removes-matching-steering-message.json +++ b/types/test-cases/reducers/008-turnstarted-with-queuedmessageid-removes-matching-steering-message.json @@ -49,6 +49,6 @@ "resource": "copilot:/test-session", "title": "Test Session", "status": 8, - "modifiedAt": "1970-01-01T00:00:01.000Z" + "modifiedAt": "1970-01-01T00:00:09.999Z" } } diff --git a/types/test-cases/reducers/009-turnstarted-without-queuedmessageid-does-not-touch-pending-messages.json b/types/test-cases/reducers/009-turnstarted-without-queuedmessageid-does-not-touch-pending-messages.json index 77bf2b7a..3775d8a4 100644 --- a/types/test-cases/reducers/009-turnstarted-without-queuedmessageid-does-not-touch-pending-messages.json +++ b/types/test-cases/reducers/009-turnstarted-without-queuedmessageid-does-not-touch-pending-messages.json @@ -78,6 +78,6 @@ "resource": "copilot:/test-session", "title": "Test Session", "status": 8, - "modifiedAt": "1970-01-01T00:00:01.000Z" + "modifiedAt": "1970-01-01T00:00:09.999Z" } } diff --git a/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json b/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json index a0cf3243..b2769329 100644 --- a/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json +++ b/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json @@ -39,7 +39,7 @@ { "type": "chat/turnComplete", "turnId": "turn-1", - "endedAt": "1970-01-01T00:00:09.999Z" + "duration": 8999 } ], "expected": { diff --git a/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json b/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json index a5bb9a97..618aefb3 100644 --- a/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json +++ b/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json @@ -24,7 +24,7 @@ { "type": "chat/turnCancelled", "turnId": "turn-1", - "endedAt": "1970-01-01T00:00:09.999Z" + "duration": 8999 } ], "expected": { diff --git a/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json b/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json index 5863a121..6a690e3f 100644 --- a/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json +++ b/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json @@ -24,7 +24,7 @@ { "type": "chat/error", "turnId": "turn-1", - "endedAt": "1970-01-01T00:00:09.999Z", + "duration": 8999, "error": { "errorType": "runtime", "message": "Something broke" diff --git a/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json b/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json index c1cec0e1..b56073aa 100644 --- a/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json +++ b/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json @@ -31,7 +31,7 @@ { "type": "chat/turnComplete", "turnId": "turn-1", - "endedAt": "1970-01-01T00:00:09.999Z" + "duration": 8999 } ], "expected": { diff --git a/types/test-cases/reducers/018-turncomplete-with-wrong-turnid-is-no-op.json b/types/test-cases/reducers/018-turncomplete-with-wrong-turnid-is-no-op.json index 5d15fb14..c95e01e2 100644 --- a/types/test-cases/reducers/018-turncomplete-with-wrong-turnid-is-no-op.json +++ b/types/test-cases/reducers/018-turncomplete-with-wrong-turnid-is-no-op.json @@ -24,7 +24,7 @@ { "type": "chat/turnComplete", "turnId": "wrong-turn", - "endedAt": "1970-01-01T00:00:09.999Z" + "duration": 8999 } ], "expected": { diff --git a/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json b/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json index 8343b1d4..95a3cbef 100644 --- a/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json +++ b/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json @@ -139,7 +139,7 @@ { "type": "chat/turnComplete", "turnId": "t1", - "endedAt": "1970-01-01T00:00:09.999Z" + "duration": 8999 } ], "expected": { diff --git a/types/test-cases/reducers/075-turnstarted-clears-isread.json b/types/test-cases/reducers/075-turnstarted-clears-isread.json index f7791425..fd06d1d9 100644 --- a/types/test-cases/reducers/075-turnstarted-clears-isread.json +++ b/types/test-cases/reducers/075-turnstarted-clears-isread.json @@ -38,6 +38,6 @@ "resource": "copilot:/test-session", "title": "Test Session", "status": 8, - "modifiedAt": "1970-01-01T00:00:01.000Z" + "modifiedAt": "1970-01-01T00:00:09.999Z" } } diff --git a/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json b/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json index 8e04a242..937e2c6f 100644 --- a/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json +++ b/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json @@ -38,7 +38,7 @@ { "type": "chat/turnComplete", "turnId": "turn-1", - "endedAt": "1970-01-01T00:00:09.999Z" + "duration": 8999 } ], "expected": { diff --git a/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json b/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json index 62ba2446..64de36e7 100644 --- a/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json +++ b/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json @@ -42,7 +42,7 @@ { "type": "chat/turnComplete", "turnId": "turn-1", - "endedAt": "1970-01-01T00:00:09.999Z" + "duration": 8999 } ], "expected": { diff --git a/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json b/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json index fa43ea16..4d04fe63 100644 --- a/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json +++ b/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json @@ -41,7 +41,7 @@ { "type": "chat/turnComplete", "turnId": "turn-1", - "endedAt": "1970-01-01T00:00:09.999Z" + "duration": 8999 } ], "expected": { diff --git a/types/test-cases/reducers/238-invalid-turn-start-timestamp-is-no-op.json b/types/test-cases/reducers/238-invalid-turn-start-timestamp-is-no-op.json deleted file mode 100644 index bc6f6d62..00000000 --- a/types/test-cases/reducers/238-invalid-turn-start-timestamp-is-no-op.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "description": "chat/turnStarted with an invalid timestamp is a no-op", - "reducer": "chat", - "initial": { - "turns": [], - "resource": "copilot:/test-session", - "title": "Test Session", - "status": 1, - "modifiedAt": "1970-01-01T00:00:01.000Z" - }, - "actions": [ - { - "type": "chat/turnStarted", - "turnId": "turn-1", - "startedAt": "July 9 2026 20:00:00", - "message": { - "text": "Hello", - "origin": { - "kind": "user" - } - } - } - ], - "expected": { - "turns": [], - "resource": "copilot:/test-session", - "title": "Test Session", - "status": 1, - "modifiedAt": "1970-01-01T00:00:01.000Z" - } -} diff --git a/types/test-cases/reducers/239-invalid-turn-end-timestamp-is-no-op.json b/types/test-cases/reducers/239-invalid-turn-end-timestamp-is-no-op.json deleted file mode 100644 index 78d664f9..00000000 --- a/types/test-cases/reducers/239-invalid-turn-end-timestamp-is-no-op.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "description": "chat/turnComplete with an invalid timestamp is a no-op", - "reducer": "chat", - "initial": { - "turns": [], - "activeTurn": { - "id": "turn-1", - "startedAt": "1970-01-01T00:00:01.000Z", - "message": { - "text": "Hello", - "origin": { - "kind": "user" - } - }, - "responseParts": [], - "usage": null - }, - "resource": "copilot:/test-session", - "title": "Test Session", - "status": 8, - "modifiedAt": "1970-01-01T00:00:01.000Z" - }, - "actions": [ - { - "type": "chat/turnComplete", - "turnId": "turn-1", - "endedAt": "2026-07-09T20:00:00.000" - } - ], - "expected": { - "turns": [], - "activeTurn": { - "id": "turn-1", - "startedAt": "1970-01-01T00:00:01.000Z", - "message": { - "text": "Hello", - "origin": { - "kind": "user" - } - }, - "responseParts": [], - "usage": null - }, - "resource": "copilot:/test-session", - "title": "Test Session", - "status": 8, - "modifiedAt": "1970-01-01T00:00:01.000Z" - } -} diff --git a/types/test-cases/reducers/240-turn-duration-is-clamped-to-zero.json b/types/test-cases/reducers/240-turn-duration-is-clamped-to-zero.json index dbe67810..ea33eebe 100644 --- a/types/test-cases/reducers/240-turn-duration-is-clamped-to-zero.json +++ b/types/test-cases/reducers/240-turn-duration-is-clamped-to-zero.json @@ -24,7 +24,7 @@ { "type": "chat/turnComplete", "turnId": "turn-1", - "endedAt": "1970-01-01T00:00:09.000Z" + "duration": -1000 } ], "expected": { @@ -49,6 +49,6 @@ "resource": "copilot:/test-session", "title": "Test Session", "status": 1, - "modifiedAt": "1970-01-01T00:00:09.000Z" + "modifiedAt": "1970-01-01T00:00:09.999Z" } }