diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f55ce7f..19cc366b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,9 @@ changes accumulate. Track in-flight protocol changes via PRs touching ### Fixed +- `chat/usage` reducer now updates the matching turn in `turns` when the + `turnId` refers to a completed (non-active) turn, rather than ignoring the + action. - Corrected the `ACTION_INTRODUCED_IN` entries for `annotations/set`, `annotations/removed`, `annotations/entrySet`, and `annotations/entryRemoved` from `0.3.0` to `0.4.0`. The annotations channel first shipped in the diff --git a/clients/go/CHANGELOG.md b/clients/go/CHANGELOG.md index e8ff02d6..e1d93a2f 100644 --- a/clients/go/CHANGELOG.md +++ b/clients/go/CHANGELOG.md @@ -43,6 +43,12 @@ tag whose matching `## [X.Y.Z]` heading is missing from this file. published tools by re-dispatching `SessionActiveClientSetAction` with its full, updated entry. +### Fixed + +- `ApplyActionToChat` now updates the matching turn in `Turns` when a + `ChatUsageAction` targets a completed (non-active) turn, rather than + ignoring the action. + ## [0.4.0] — 2026-06-19 Implements AHP 0.4.0. diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 1348b7a3..59543cdf 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -472,12 +472,19 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R return tc }) case *ahptypes.ChatUsageAction: - if state.ActiveTurn == nil || state.ActiveTurn.Id != a.TurnId { - return ReduceOutcomeNoOp + if state.ActiveTurn != nil && state.ActiveTurn.Id == a.TurnId { + usage := a.Usage + state.ActiveTurn.Usage = &usage + return ReduceOutcomeApplied } - usage := a.Usage - state.ActiveTurn.Usage = &usage - return ReduceOutcomeApplied + for i := range state.Turns { + if state.Turns[i].Id == a.TurnId { + usage := a.Usage + state.Turns[i].Usage = &usage + return ReduceOutcomeApplied + } + } + return ReduceOutcomeNoOp case *ahptypes.ChatReasoningAction: return updateResponsePart(state, a.TurnId, a.PartId, func(p *ahptypes.ResponsePart) { if r, ok := p.Value.(*ahptypes.ReasoningResponsePart); ok { diff --git a/clients/kotlin/CHANGELOG.md b/clients/kotlin/CHANGELOG.md index 9a564c32..7aac71c3 100644 --- a/clients/kotlin/CHANGELOG.md +++ b/clients/kotlin/CHANGELOG.md @@ -45,6 +45,12 @@ versions (`*-SNAPSHOT`) are explicitly rejected by the publish pipeline; bump published tools by re-dispatching `StateActionSessionActiveClientSet` with its full, updated entry. +### Fixed + +- `chatReducer` now updates the matching turn in `turns` when a + `StateActionChatUsage` targets a completed (non-active) turn, rather than + ignoring the action. + ## [0.4.0] — 2026-06-19 Implements AHP 0.4.0. 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 7d78ad5d..1409aec9 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -975,10 +975,17 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when is StateActionChatUsage -> { val a = action.value val activeTurn = state.activeTurn - if (activeTurn == null || activeTurn.id != a.turnId) { - state - } else { + if (activeTurn != null && activeTurn.id == a.turnId) { state.copy(activeTurn = activeTurn.copy(usage = a.usage)) + } else { + val idx = state.turns.indexOfFirst { it.id == a.turnId } + if (idx < 0) { + state + } else { + val turns = state.turns.toMutableList() + turns[idx] = turns[idx].copy(usage = a.usage) + state.copy(turns = turns) + } } } diff --git a/clients/rust/CHANGELOG.md b/clients/rust/CHANGELOG.md index ed90eda0..d426b4e5 100644 --- a/clients/rust/CHANGELOG.md +++ b/clients/rust/CHANGELOG.md @@ -52,6 +52,12 @@ matching `## [X.Y.Z]` heading is missing from this file. published tools by re-dispatching `SessionActiveClientSet` with its full, updated entry. +### Fixed + +- `apply_action_to_chat` now updates the matching turn in `turns` when a + `StateAction::ChatUsage` targets a completed (non-active) turn, rather than + ignoring the action. + ## [0.4.0] — 2026-06-19 Implements AHP 0.4.0. diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 74e83755..c0f220d2 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -850,13 +850,16 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu } StateAction::ChatToolCallContentChanged(a) => apply_tool_call_content_changed(state, a), StateAction::ChatUsage(a) => { - let Some(active) = state.active_turn.as_mut() else { + if let Some(active) = state.active_turn.as_mut() { + if active.id == a.turn_id { + active.usage = Some(a.usage.clone()); + return ReduceOutcome::Applied; + } + } + let Some(turn) = state.turns.iter_mut().find(|t| t.id == a.turn_id) else { return ReduceOutcome::NoOp; }; - if active.id != a.turn_id { - return ReduceOutcome::NoOp; - } - active.usage = Some(a.usage.clone()); + turn.usage = Some(a.usage.clone()); ReduceOutcome::Applied } StateAction::ChatReasoning(a) => update_response_part(state, &a.turn_id, &a.part_id, |p| { diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 9ce944a4..a90e3e19 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -338,12 +338,17 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { } case .chatUsage(let a): - guard var activeTurn = state.activeTurn, activeTurn.id == a.turnId else { + if var activeTurn = state.activeTurn, activeTurn.id == a.turnId { + activeTurn.usage = a.usage + var next = state + next.activeTurn = activeTurn + return next + } + guard let idx = state.turns.firstIndex(where: { $0.id == a.turnId }) else { return state } - activeTurn.usage = a.usage var next = state - next.activeTurn = activeTurn + next.turns[idx].usage = a.usage return next case .chatReasoning(let a): diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md index c6d857ac..7950135c 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -47,6 +47,12 @@ the tag matches the version pinned in [`VERSION`](VERSION). published tools by re-dispatching `StateAction.sessionActiveClientSet` with its full, updated entry. +### Fixed + +- `chatReducer` now updates the matching turn in `turns` when a + `StateAction.chatUsage` targets a completed (non-active) turn, rather than + ignoring the action. + ## [0.4.0] — 2026-06-19 Implements AHP 0.4.0. diff --git a/clients/typescript/CHANGELOG.md b/clients/typescript/CHANGELOG.md index ab7e9ef7..eaec32e0 100644 --- a/clients/typescript/CHANGELOG.md +++ b/clients/typescript/CHANGELOG.md @@ -51,6 +51,8 @@ hotfix escape hatch. ### Fixed +- `chatReducer` now updates the matching turn in `turns` when a `chat/usage` + action targets a completed (non-active) turn, rather than ignoring the action. - Hosted session summary caches now apply `_meta` updates from `root/sessionSummaryChanged` notifications. - Corrected the `ACTION_INTRODUCED_IN` entries for `annotations/set`, diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index 24d93841..161b9589 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -502,14 +502,21 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st }); - case ActionType.ChatUsage: - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + case ActionType.ChatUsage: { + if (state.activeTurn && state.activeTurn.id === action.turnId) { + return { + ...state, + activeTurn: { ...state.activeTurn, usage: action.usage }, + }; + } + const idx = state.turns.findIndex(t => t.id === action.turnId); + if (idx === -1) { return state; } - return { - ...state, - activeTurn: { ...state.activeTurn, usage: action.usage }, - }; + const turns = state.turns.slice(); + turns[idx] = { ...turns[idx], usage: action.usage }; + return { ...state, turns }; + } case ActionType.ChatReasoning: return updateResponsePart(state, action.turnId, action.partId, part => {