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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions clients/go/ahp/reducers.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ 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 string, duration int64, turnState ahptypes.TurnState, terminalStatus *ahptypes.SessionStatus, errInfo *ahptypes.ErrorInfo) ReduceOutcome {
if state.ActiveTurn == nil || state.ActiveTurn.Id != turnID {
return ReduceOutcomeNoOp
}
Expand Down Expand Up @@ -220,8 +220,16 @@ func endTurn(state *ahptypes.ChatState, turnID string, turnState ahptypes.TurnSt
}})
}

// 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,
Message: active.Message,
ResponseParts: parts,
Usage: active.Usage,
Expand All @@ -231,7 +239,7 @@ func endTurn(state *ahptypes.ChatState, turnID string, turnState ahptypes.TurnSt

state.Turns = append(state.Turns, turn)
state.InputRequests = nil
touchChatModified(state)
state.ModifiedAt = nowISOString()
state.Status = summaryStatus(state, terminalStatus)
return ReduceOutcomeApplied
}
Expand Down Expand Up @@ -464,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, ahptypes.TurnStateComplete, nil, nil)
return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateComplete, nil, nil)
case *ahptypes.ChatTurnCancelledAction:
return endTurn(state, a.TurnId, 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, ahptypes.TurnStateError, &errStatus, &errCopy)
return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateError, &errStatus, &errCopy)
case *ahptypes.ChatActivityChangedAction:
state.Activity = a.Activity
return ReduceOutcomeApplied
Expand Down Expand Up @@ -924,11 +932,12 @@ func ApplyActionToSession(state *ahptypes.SessionState, action ahptypes.StateAct
func applyTurnStarted(state *ahptypes.ChatState, a *ahptypes.ChatTurnStartedAction) ReduceOutcome {
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 = nowISOString()
state.Status = withStatusFlag(state.Status, ahptypes.SessionStatusIsRead, false)

if a.QueuedMessageId != nil {
Expand Down
17 changes: 17 additions & 0 deletions clients/go/ahptypes/actions.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -460,6 +462,11 @@ type ChatTurnCompleteAction struct {
Type ActionType `json:"type"`
// Turn identifier
TurnId string `json:"turnId"`
// 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
Expand All @@ -475,6 +482,11 @@ type ChatTurnCancelledAction struct {
Type ActionType `json:"type"`
// Turn identifier
TurnId string `json:"turnId"`
// 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
Expand All @@ -490,6 +502,11 @@ type ChatErrorAction struct {
Type ActionType `json:"type"`
// Turn identifier
TurnId string `json:"turnId"`
// 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.
Expand Down
6 changes: 6 additions & 0 deletions clients/go/ahptypes/state.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
12 changes: 7 additions & 5 deletions clients/go/examples/reducers_demo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -40,8 +41,9 @@ func main() {
Content: "there!",
}},
{Value: &ahptypes.ChatTurnCompleteAction{
Type: ahptypes.ActionTypeChatTurnComplete,
TurnId: "t1",
Type: ahptypes.ActionTypeChatTurnComplete,
TurnId: "t1",
Duration: 1000,
}},
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ private fun updateResponsePart(
private fun endTurn(
state: ChatState,
turnId: String,
duration: Long,
turnState: TurnState,
terminalStatus: SessionStatus? = null,
error: ErrorInfo? = null,
Expand Down Expand Up @@ -386,8 +387,12 @@ private fun endTurn(
)
}

// 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 = maxOf(0L, duration),
message = active.message,
responseParts = finalizedParts,
usage = active.usage,
Expand Down Expand Up @@ -747,6 +752,7 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when
val withTurn = state.copy(
activeTurn = ActiveTurn(
id = a.turnId,
startedAt = a.startedAt,
message = a.message,
responseParts = emptyList(),
usage = null,
Expand Down Expand Up @@ -796,13 +802,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.duration, TurnState.COMPLETE)

is StateActionChatTurnCancelled ->
endTurn(state, action.value.turnId, TurnState.CANCELLED)
endTurn(state, action.value.turnId, action.value.duration, TurnState.CANCELLED)

is StateActionChatError ->
endTurn(state, action.value.turnId, 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -609,6 +613,13 @@ data class ChatTurnCompleteAction(
* Turn identifier
*/
val turnId: 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.
*/
val duration: Long,
/**
* Additional provider-specific metadata for this action.
*
Expand All @@ -629,6 +640,13 @@ data class ChatTurnCancelledAction(
* Turn identifier
*/
val turnId: 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.
*/
val duration: Long,
/**
* Additional provider-specific metadata for this action.
*
Expand All @@ -649,6 +667,13 @@ data class ChatErrorAction(
* Turn identifier
*/
val turnId: 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.
*/
val duration: Long,
/**
* Error details
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,23 +209,20 @@ 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 stores producer timestamp while modifiedAt uses local clock`() {
val chatResult = chatReducer(
newChat(),
StateActionChatTurnStarted(
ChatTurnStartedAction(
type = ActionType.CHAT_TURN_STARTED,
turnId = "turn-1",
startedAt = "1970-01-01T00:00:12.345Z",
message = userMessage("hello"),
),
),
)
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
Expand Down
17 changes: 17 additions & 0 deletions clients/rust/crates/ahp-types/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -613,6 +615,11 @@ pub struct ChatToolCallContentChangedAction {
pub struct ChatTurnCompleteAction {
/// Turn identifier
pub turn_id: 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
Expand All @@ -630,6 +637,11 @@ pub struct ChatTurnCompleteAction {
pub struct ChatTurnCancelledAction {
/// Turn identifier
pub turn_id: 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
Expand All @@ -647,6 +659,11 @@ pub struct ChatTurnCancelledAction {
pub struct ChatErrorAction {
/// Turn identifier
pub turn_id: 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.
Expand Down
8 changes: 8 additions & 0 deletions clients/rust/crates/ahp-types/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Turn duration in milliseconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration: Option<i64>,
/// The message that initiated the turn
pub message: Message,
/// All response content in stream order: text, tool calls, reasoning, and content refs.
Expand All @@ -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.
Expand Down
Loading
Loading