diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 9d2a3416..9dafb92e 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -215,7 +215,7 @@ func touchChatModified(state *ahptypes.ChatState) { // ─── Active-turn helpers ─────────────────────────────────────────────── -func endTurn(state *ahptypes.ChatState, turnID string, duration int64, 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, resumable *bool) ReduceOutcome { if state.ActiveTurn == nil || state.ActiveTurn.Id != turnID { return ReduceOutcomeNoOp } @@ -269,6 +269,7 @@ func endTurn(state *ahptypes.ChatState, turnID string, duration int64, turnState Usage: active.Usage, State: turnState, Error: errInfo, + Resumable: resumable, } state.Turns = append(state.Turns, turn) @@ -505,6 +506,8 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R switch a := action.Value.(type) { case *ahptypes.ChatTurnStartedAction: return applyTurnStarted(state, a) + case *ahptypes.ChatTurnResumedAction: + return applyTurnResumed(state, a) case *ahptypes.ChatDeltaAction: return updateResponsePart(state, a.TurnId, a.PartId, func(p *ahptypes.ResponsePart) { if m, ok := p.Value.(*ahptypes.MarkdownResponsePart); ok { @@ -518,13 +521,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.Duration, ahptypes.TurnStateComplete, nil, nil) + return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateComplete, nil, nil, nil) case *ahptypes.ChatTurnCancelledAction: - return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateCancelled, nil, nil) + return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateCancelled, nil, nil, nil) case *ahptypes.ChatErrorAction: errCopy := a.Error errStatus := ahptypes.SessionStatusError - return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateError, &errStatus, &errCopy) + return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateError, &errStatus, &errCopy, a.Resumable) case *ahptypes.ChatActivityChangedAction: state.Activity = a.Activity return ReduceOutcomeApplied @@ -1034,6 +1037,35 @@ func applyTurnStarted(state *ahptypes.ChatState, a *ahptypes.ChatTurnStartedActi return ReduceOutcomeApplied } +func applyTurnResumed(state *ahptypes.ChatState, a *ahptypes.ChatTurnResumedAction) ReduceOutcome { + if state.ActiveTurn != nil || len(state.Turns) == 0 { + return ReduceOutcomeNoOp + } + turnIndex := len(state.Turns) - 1 + turn := state.Turns[turnIndex] + if turn.Id != a.TurnId || turn.State != ahptypes.TurnStateError || + turn.Resumable == nil || !*turn.Resumable { + return ReduceOutcomeNoOp + } + + startedAt := state.ModifiedAt + if turn.StartedAt != nil { + startedAt = *turn.StartedAt + } + state.Turns = state.Turns[:turnIndex] + state.ActiveTurn = &ahptypes.ActiveTurn{ + Id: turn.Id, + StartedAt: startedAt, + Message: turn.Message, + ResponseParts: turn.ResponseParts, + Usage: turn.Usage, + } + state.Status = summaryStatus(state, nil) + state.ModifiedAt = nowISOString() + state.Status = withStatusFlag(state.Status, ahptypes.SessionStatusIsRead, false) + return ReduceOutcomeApplied +} + func applyToolCallDelta(state *ahptypes.ChatState, a *ahptypes.ChatToolCallDeltaAction) ReduceOutcome { return updateToolCall(state, a.TurnId, a.ToolCallId, func(tc ahptypes.ToolCallState) ahptypes.ToolCallState { s, ok := tc.Value.(*ahptypes.ToolCallStreamingState) diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index fccdb904..29f14118 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -28,6 +28,7 @@ const ( ActionTypeSessionChatUpdated ActionType = "session/chatUpdated" ActionTypeSessionDefaultChatChanged ActionType = "session/defaultChatChanged" ActionTypeChatTurnStarted ActionType = "chat/turnStarted" + ActionTypeChatTurnResumed ActionType = "chat/turnResumed" ActionTypeChatDelta ActionType = "chat/delta" ActionTypeChatResponsePart ActionType = "chat/responsePart" ActionTypeChatToolCallStart ActionType = "chat/toolCallStart" @@ -234,6 +235,13 @@ type ChatTurnStartedAction struct { Meta map[string]json.RawMessage `json:"_meta,omitempty"` } +// Resumes a failed turn without adding another message. +type ChatTurnResumedAction struct { + Type ActionType `json:"type"` + // Identifier of the resumable failed turn. + TurnId string `json:"turnId"` +} + // Streaming text chunk from the assistant, appended to a specific response part. // // The server MUST first emit a `chat/responsePart` to create the target @@ -591,6 +599,8 @@ type ChatErrorAction struct { Duration int64 `json:"duration"` // Error details Error ErrorInfo `json:"error"` + // Whether the failed turn can be resumed without adding another message. + Resumable *bool `json:"resumable,omitempty"` // Additional provider-specific metadata for this action. // // Clients MAY look for well-known keys here to provide enhanced UI, and @@ -1501,6 +1511,7 @@ func (*SessionChatRemovedAction) isStateAction() {} func (*SessionChatUpdatedAction) isStateAction() {} func (*SessionDefaultChatChangedAction) isStateAction() {} func (*ChatTurnStartedAction) isStateAction() {} +func (*ChatTurnResumedAction) isStateAction() {} func (*ChatDeltaAction) isStateAction() {} func (*ChatResponsePartAction) isStateAction() {} func (*ChatToolCallStartAction) isStateAction() {} @@ -1651,6 +1662,12 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "chat/turnResumed": + var value ChatTurnResumedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value case "chat/delta": var value ChatDeltaAction if err := json.Unmarshal(data, &value); err != nil { diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 99693a7b..61ad4c0d 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -1287,6 +1287,8 @@ type Turn struct { State TurnState `json:"state"` // Error details if state is `'error'` Error *ErrorInfo `json:"error,omitempty"` + // Whether this failed turn can be resumed without adding another message. + Resumable *bool `json:"resumable,omitempty"` } // An in-progress turn — the assistant is actively streaming. 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 6222ed85..138aa4dc 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -379,6 +379,7 @@ private fun endTurn( turnState: TurnState, terminalStatus: SessionStatus? = null, error: ErrorInfo? = null, + resumable: Boolean? = null, ): ChatState { val active = state.activeTurn ?: return state if (active.id != turnId) return state @@ -434,6 +435,7 @@ private fun endTurn( usage = active.usage, state = turnState, error = error, + resumable = resumable, ) val withoutTurn = state.copy( @@ -849,6 +851,35 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when } } + is StateActionChatTurnResumed -> { + val a = action.value + val turn = state.turns.lastOrNull() + if ( + state.activeTurn != null || + turn == null || + turn.id != a.turnId || + turn.state != TurnState.ERROR || + turn.resumable != true + ) { + state + } else { + val withTurn = state.copy( + turns = state.turns.dropLast(1), + activeTurn = ActiveTurn( + id = turn.id, + startedAt = turn.startedAt ?: state.modifiedAt, + message = turn.message, + responseParts = turn.responseParts, + usage = turn.usage, + ), + ) + withTurn.copy( + status = withStatusFlag(chatSummaryStatus(withTurn), SessionStatus.IS_READ, false), + modifiedAt = nowIsoString(), + ) + } + } + is StateActionChatDelta -> { val a = action.value updateResponsePart(state, a.turnId, a.partId) { part -> @@ -879,7 +910,7 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when endTurn(state, action.value.turnId, action.value.duration, TurnState.CANCELLED) is StateActionChatError -> - endTurn(state, action.value.turnId, action.value.duration, TurnState.ERROR, SessionStatus.ERROR, action.value.error) + endTurn(state, action.value.turnId, action.value.duration, TurnState.ERROR, SessionStatus.ERROR, action.value.error, action.value.resumable) 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 cde9f9ed..c941d243 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 @@ -44,6 +44,8 @@ enum class ActionType { SESSION_DEFAULT_CHAT_CHANGED, @SerialName("chat/turnStarted") CHAT_TURN_STARTED, + @SerialName("chat/turnResumed") + CHAT_TURN_RESUMED, @SerialName("chat/delta") CHAT_DELTA, @SerialName("chat/responsePart") @@ -327,6 +329,15 @@ data class ChatTurnStartedAction( val meta: Map? = null ) +@Serializable +data class ChatTurnResumedAction( + val type: ActionType, + /** + * Identifier of the resumable failed turn. + */ + val turnId: String +) + @Serializable data class ChatDeltaAction( val type: ActionType, @@ -754,6 +765,10 @@ data class ChatErrorAction( * Error details */ val error: ErrorInfo, + /** + * Whether the failed turn can be resumed without adding another message. + */ + val resumable: Boolean? = null, /** * Additional provider-specific metadata for this action. * @@ -1546,6 +1561,7 @@ sealed interface StateAction @JvmInline value class StateActionSessionChatUpdated(val value: SessionChatUpdatedAction) : StateAction @JvmInline value class StateActionSessionDefaultChatChanged(val value: SessionDefaultChatChangedAction) : StateAction @JvmInline value class StateActionChatTurnStarted(val value: ChatTurnStartedAction) : StateAction +@JvmInline value class StateActionChatTurnResumed(val value: ChatTurnResumedAction) : StateAction @JvmInline value class StateActionChatDelta(val value: ChatDeltaAction) : StateAction @JvmInline value class StateActionChatResponsePart(val value: ChatResponsePartAction) : StateAction @JvmInline value class StateActionChatToolCallStart(val value: ChatToolCallStartAction) : StateAction @@ -1646,6 +1662,7 @@ internal object StateActionSerializer : KSerializer { "session/chatUpdated" -> StateActionSessionChatUpdated(input.json.decodeFromJsonElement(SessionChatUpdatedAction.serializer(), element)) "session/defaultChatChanged" -> StateActionSessionDefaultChatChanged(input.json.decodeFromJsonElement(SessionDefaultChatChangedAction.serializer(), element)) "chat/turnStarted" -> StateActionChatTurnStarted(input.json.decodeFromJsonElement(ChatTurnStartedAction.serializer(), element)) + "chat/turnResumed" -> StateActionChatTurnResumed(input.json.decodeFromJsonElement(ChatTurnResumedAction.serializer(), element)) "chat/delta" -> StateActionChatDelta(input.json.decodeFromJsonElement(ChatDeltaAction.serializer(), element)) "chat/responsePart" -> StateActionChatResponsePart(input.json.decodeFromJsonElement(ChatResponsePartAction.serializer(), element)) "chat/toolCallStart" -> StateActionChatToolCallStart(input.json.decodeFromJsonElement(ChatToolCallStartAction.serializer(), element)) @@ -1739,6 +1756,7 @@ internal object StateActionSerializer : KSerializer { is StateActionSessionChatUpdated -> output.json.encodeToJsonElement(SessionChatUpdatedAction.serializer(), value.value) is StateActionSessionDefaultChatChanged -> output.json.encodeToJsonElement(SessionDefaultChatChangedAction.serializer(), value.value) is StateActionChatTurnStarted -> output.json.encodeToJsonElement(ChatTurnStartedAction.serializer(), value.value) + is StateActionChatTurnResumed -> output.json.encodeToJsonElement(ChatTurnResumedAction.serializer(), value.value) is StateActionChatDelta -> output.json.encodeToJsonElement(ChatDeltaAction.serializer(), value.value) is StateActionChatResponsePart -> output.json.encodeToJsonElement(ChatResponsePartAction.serializer(), value.value) is StateActionChatToolCallStart -> output.json.encodeToJsonElement(ChatToolCallStartAction.serializer(), value.value) 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 d6fe2b4e..40964098 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 @@ -1734,7 +1734,11 @@ data class Turn( /** * Error details if state is `'error'` */ - val error: ErrorInfo? = null + val error: ErrorInfo? = null, + /** + * Whether this failed turn can be resumed without adding another message. + */ + val resumable: Boolean? = null ) @Serializable diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 849a339c..c48bc2a8 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -46,6 +46,8 @@ pub enum ActionType { SessionDefaultChatChanged, #[serde(rename = "chat/turnStarted")] ChatTurnStarted, + #[serde(rename = "chat/turnResumed")] + ChatTurnResumed, #[serde(rename = "chat/delta")] ChatDelta, #[serde(rename = "chat/responsePart")] @@ -352,6 +354,14 @@ pub struct ChatTurnStartedAction { pub meta: Option, } +/// Resumes a failed turn without adding another message. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatTurnResumedAction { + /// Identifier of the resumable failed turn. + pub turn_id: String, +} + /// Streaming text chunk from the assistant, appended to a specific response part. /// /// The server MUST first emit a `chat/responsePart` to create the target @@ -763,6 +773,9 @@ pub struct ChatErrorAction { pub duration: i64, /// Error details pub error: ErrorInfo, + /// Whether the failed turn can be resumed without adding another message. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resumable: Option, /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -1799,6 +1812,8 @@ pub enum StateAction { SessionDefaultChatChanged(SessionDefaultChatChangedAction), #[serde(rename = "chat/turnStarted")] ChatTurnStarted(ChatTurnStartedAction), + #[serde(rename = "chat/turnResumed")] + ChatTurnResumed(ChatTurnResumedAction), #[serde(rename = "chat/delta")] ChatDelta(ChatDeltaAction), #[serde(rename = "chat/responsePart")] diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 0e9f5cba..44533099 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -1619,6 +1619,9 @@ pub struct Turn { /// Error details if state is `'error'` #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, + /// Whether this failed turn can be resumed without adding another message. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resumable: Option, } /// An in-progress turn — the assistant is actively streaming. diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 027f48e0..2f7e30fb 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -55,7 +55,7 @@ use ahp_types::actions::{ ChatInputAnswerChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatToolCallCompleteAction, ChatToolCallConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallResultConfirmedAction, - ChatTurnStartedAction, StateAction, + ChatTurnResumedAction, ChatTurnStartedAction, StateAction, }; use ahp_types::state::{ ActiveTurn, AnnotationsState, ChangesetOperationStatus, ChangesetState, ChangesetStatus, @@ -341,6 +341,7 @@ fn end_turn( turn_state: TurnState, terminal_status: Option, error: Option, + resumable: Option, ) -> ReduceOutcome { let Some(active) = state.active_turn.as_ref() else { return ReduceOutcome::NoOp; @@ -409,6 +410,7 @@ fn end_turn( usage: active.usage, state: turn_state, error, + resumable, }; state.turns.push(turn); @@ -955,6 +957,7 @@ fn update_mcp_server_customization_state( pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> ReduceOutcome { match action { StateAction::ChatTurnStarted(a) => apply_turn_started(state, a), + StateAction::ChatTurnResumed(a) => apply_turn_resumed(state, a), StateAction::ChatDelta(a) => update_response_part(state, &a.turn_id, &a.part_id, |p| { if let ResponsePart::Markdown(m) = p { m.content.push_str(&a.content); @@ -977,6 +980,7 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu TurnState::Complete, None, None, + None, ), StateAction::ChatTurnCancelled(a) => end_turn( state, @@ -985,6 +989,7 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu TurnState::Cancelled, None, None, + None, ), StateAction::ChatError(a) => end_turn( state, @@ -993,6 +998,7 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu TurnState::Error, Some(SessionStatus::Error), Some(a.error.clone()), + a.resumable, ), StateAction::ChatActivityChanged(a) => { state.activity = a.activity.clone(); @@ -1248,6 +1254,31 @@ fn apply_turn_started(state: &mut ChatState, a: &ChatTurnStartedAction) -> Reduc ReduceOutcome::Applied } +fn apply_turn_resumed(state: &mut ChatState, a: &ChatTurnResumedAction) -> ReduceOutcome { + if state.active_turn.is_some() { + return ReduceOutcome::NoOp; + } + let Some(turn) = state.turns.last() else { + return ReduceOutcome::NoOp; + }; + if turn.id != a.turn_id || turn.state != TurnState::Error || turn.resumable != Some(true) { + return ReduceOutcome::NoOp; + } + + let turn = state.turns.pop().expect("last turn must exist"); + state.active_turn = Some(ActiveTurn { + id: turn.id, + started_at: turn.started_at.unwrap_or_else(|| state.modified_at.clone()), + message: turn.message, + response_parts: turn.response_parts, + usage: turn.usage, + }); + state.status = summary_status(state, None); + touch_chat_modified(state); + state.status = with_status_flag(state.status, SessionStatus::IsRead, false); + ReduceOutcome::Applied +} + fn apply_tool_call_delta(state: &mut ChatState, a: &ChatToolCallDeltaAction) -> ReduceOutcome { update_tool_call(state, &a.turn_id, &a.tool_call_id, |tc| match tc { ToolCallState::Streaming(mut s) => { diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index da101cfd..411fed14 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -15,6 +15,7 @@ public enum ActionType: String, Codable, Sendable { case sessionChatUpdated = "session/chatUpdated" case sessionDefaultChatChanged = "session/defaultChatChanged" case chatTurnStarted = "chat/turnStarted" + case chatTurnResumed = "chat/turnResumed" case chatDelta = "chat/delta" case chatResponsePart = "chat/responsePart" case chatToolCallStart = "chat/toolCallStart" @@ -293,6 +294,20 @@ public struct ChatTurnStartedAction: Codable, Sendable { } } +public struct ChatTurnResumedAction: Codable, Sendable { + public var type: ActionType + /// Identifier of the resumable failed turn. + public var turnId: String + + public init( + type: ActionType, + turnId: String + ) { + self.type = type + self.turnId = turnId + } +} + public struct ChatDeltaAction: Codable, Sendable { public var type: ActionType /// Turn identifier @@ -895,6 +910,8 @@ public struct ChatErrorAction: Codable, Sendable { public var duration: Int /// Error details public var error: ErrorInfo + /// Whether the failed turn can be resumed without adding another message. + public var resumable: Bool? /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -909,6 +926,7 @@ public struct ChatErrorAction: Codable, Sendable { case turnId case duration case error + case resumable case meta = "_meta" } @@ -917,12 +935,14 @@ public struct ChatErrorAction: Codable, Sendable { turnId: String, duration: Int, error: ErrorInfo, + resumable: Bool? = nil, meta: [String: AnyCodable]? = nil ) { self.type = type self.turnId = turnId self.duration = duration self.error = error + self.resumable = resumable self.meta = meta } } @@ -2026,6 +2046,7 @@ public enum StateAction: Codable, Sendable { case sessionChatUpdated(SessionChatUpdatedAction) case sessionDefaultChatChanged(SessionDefaultChatChangedAction) case chatTurnStarted(ChatTurnStartedAction) + case chatTurnResumed(ChatTurnResumedAction) case chatDelta(ChatDeltaAction) case chatResponsePart(ChatResponsePartAction) case chatToolCallStart(ChatToolCallStartAction) @@ -2132,6 +2153,8 @@ public enum StateAction: Codable, Sendable { self = .sessionDefaultChatChanged(try SessionDefaultChatChangedAction(from: decoder)) case "chat/turnStarted": self = .chatTurnStarted(try ChatTurnStartedAction(from: decoder)) + case "chat/turnResumed": + self = .chatTurnResumed(try ChatTurnResumedAction(from: decoder)) case "chat/delta": self = .chatDelta(try ChatDeltaAction(from: decoder)) case "chat/responsePart": @@ -2300,6 +2323,7 @@ public enum StateAction: Codable, Sendable { case .sessionChatUpdated(let v): try v.encode(to: encoder) case .sessionDefaultChatChanged(let v): try v.encode(to: encoder) case .chatTurnStarted(let v): try v.encode(to: encoder) + case .chatTurnResumed(let v): try v.encode(to: encoder) case .chatDelta(let v): try v.encode(to: encoder) case .chatResponsePart(let v): try v.encode(to: encoder) case .chatToolCallStart(let v): try v.encode(to: encoder) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 7ec7dd9b..78f83498 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -1547,6 +1547,8 @@ public struct Turn: Codable, Sendable { public var state: TurnState /// Error details if state is `'error'` public var error: ErrorInfo? + /// Whether this failed turn can be resumed without adding another message. + public var resumable: Bool? public init( id: String, @@ -1556,7 +1558,8 @@ public struct Turn: Codable, Sendable { responseParts: [ResponsePart], usage: UsageInfo? = nil, state: TurnState, - error: ErrorInfo? = nil + error: ErrorInfo? = nil, + resumable: Bool? = nil ) { self.id = id self.startedAt = startedAt @@ -1566,6 +1569,7 @@ public struct Turn: Codable, Sendable { self.usage = usage self.state = state self.error = error + self.resumable = resumable } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 4dfa1c2c..3615ecc1 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -153,6 +153,27 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { next.status = withStatusFlag(chatSummaryStatus(next), .isRead, false) return next + case .chatTurnResumed(let a): + guard state.activeTurn == nil, + let turn = state.turns.last, + turn.id == a.turnId, + turn.state == .error, + turn.resumable == true else { + return state + } + var next = state + next.turns.removeLast() + next.activeTurn = ActiveTurn( + id: turn.id, + startedAt: turn.startedAt ?? state.modifiedAt, + message: turn.message, + responseParts: turn.responseParts, + usage: turn.usage + ) + next.modifiedAt = currentTimestamp() + next.status = withStatusFlag(chatSummaryStatus(next), .isRead, false) + return next + case .chatDelta(let a): return updateResponsePart(state: state, turnId: a.turnId, partId: a.partId) { part in guard case .markdown(var md) = part else { return part } @@ -176,7 +197,7 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { return endTurn(state: state, turnId: a.turnId, duration: a.duration, turnState: .cancelled) case .chatError(let a): - return endTurn(state: state, turnId: a.turnId, duration: a.duration, turnState: .error, terminalStatus: .error, error: a.error) + return endTurn(state: state, turnId: a.turnId, duration: a.duration, turnState: .error, terminalStatus: .error, error: a.error, resumable: a.resumable) case .chatActivityChanged(let a): var next = state @@ -1049,7 +1070,8 @@ private func endTurn( duration: Int, turnState: TurnState, terminalStatus: SessionStatus? = nil, - error: ErrorInfo? = nil + error: ErrorInfo? = nil, + resumable: Bool? = nil ) -> ChatState { guard let activeTurn = state.activeTurn, activeTurn.id == turnId else { return state @@ -1106,7 +1128,8 @@ private func endTurn( responseParts: responseParts, usage: activeTurn.usage, state: turnState, - error: error + error: error, + resumable: resumable ) var next = state diff --git a/docs/.changes/20260717-resume-failed-turn.json b/docs/.changes/20260717-resume-failed-turn.json new file mode 100644 index 00000000..7e516b3e --- /dev/null +++ b/docs/.changes/20260717-resume-failed-turn.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "`chat/turnResumed` and per-turn `resumable` eligibility let clients continue failed turns without adding another user message." +} diff --git a/docs/guide/actions.md b/docs/guide/actions.md index b863ff52..b2951ef6 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -50,6 +50,7 @@ When a client dispatches an action, the server applies it to the state and also | Type | Client-dispatchable? | When | |---|---|---| | `chat/turnStarted` | **Yes** | User sent a message; server starts processing | +| `chat/turnResumed` | **Yes** | The most recent resumable failed turn is reopened without another user message | | `chat/delta` | No | Streaming text chunk appended to a response part by `partId` | | `chat/responsePart` | No | New response part created (markdown, reasoning, content ref, tool call) | | `chat/reasoning` | No | Reasoning/thinking text appended to a reasoning part by `partId` | @@ -187,6 +188,7 @@ The client applies the action **optimistically** to its local state before sendi | Action | Server-side effect | |---|---| | `chat/turnStarted` | Begins agent processing for the new turn | +| `chat/turnResumed` | Continues agent processing for the most recent resumable failed turn without adding user input | | `chat/toolCallConfirmed` | Approves or denies a pending tool call; unblocks or cancels tool execution | | `chat/turnCancelled` | Aborts the in-progress turn | | `session/titleChanged` | Updates the session title (rename) | diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 647646ac..48af33f1 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -170,6 +170,7 @@ the host already materialized when it accepted the containing message. Once a chat exists and its session is `lifecycle: 'ready'`, the chat accepts turns. The wire shape mirrors the legacy single-chat session shape: - The client dispatches `chat/turnStarted` to begin a turn. +- After the most recent turn ends in a resumable error, the client may dispatch `chat/turnResumed` to continue it without adding another message. - The server streams `chat/delta`, `chat/responsePart`, `chat/toolCallStart`, `chat/toolCallReady`, and related actions. - The client dispatches `chat/toolCallConfirmed` / `chat/toolCallResultConfirmed` to approve or deny tool calls, or `chat/turnCancelled` to abort. - The server dispatches `chat/turnComplete` or `chat/error` when the turn ends. @@ -224,6 +225,7 @@ When the server receives a client-dispatched action on this channel, it MUST val | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | Any action referencing a non-existent chat | Channel URI not found | Server MUST silently ignore the action (no echo) | | `chat/toolCallConfirmed` | Tool call not in `pending-confirmation` state | Server MUST reject the action | +| `chat/turnResumed` | The target is not the most recent turn, it is not failed with `resumable: true`, or another turn is active | Server MUST reject the action | | `chat/turnCancelled` | No active turn | Server MUST reject the action | | `chat/inputAnswerChanged` | No input request with matching `requestId` | Server SHOULD reject the action | | `chat/inputAnswerChanged` | `answer.state` requires a value but `answer.value` is absent, or `answer.value.kind` is missing the matching payload field | Server SHOULD reject the action | diff --git a/schema/actions.schema.json b/schema/actions.schema.json index fffbb460..76b26d9c 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -707,6 +707,23 @@ "message" ] }, + "ChatTurnResumedAction": { + "type": "object", + "description": "Resumes a failed turn without adding another message.", + "properties": { + "type": { + "const": "chat/turnResumed" + }, + "turnId": { + "type": "string", + "description": "Identifier of the resumable failed turn." + } + }, + "required": [ + "type", + "turnId" + ] + }, "ChatDeltaAction": { "type": "object", "description": "Streaming text chunk from the assistant, appended to a specific response part.\n\nThe server MUST first emit a `chat/responsePart` to create the target\npart (markdown or reasoning), then use this action to append text to it.", @@ -1262,6 +1279,10 @@ "$ref": "#/$defs/ErrorInfo", "description": "Error details" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed turn can be resumed without adding another message." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -2103,6 +2124,9 @@ { "$ref": "#/$defs/ChatTurnStartedAction" }, + { + "$ref": "#/$defs/ChatTurnResumedAction" + }, { "$ref": "#/$defs/ChatDeltaAction" }, @@ -5117,6 +5141,10 @@ "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details if state is `'error'`" + }, + "resumable": { + "type": "boolean", + "description": "Whether this failed turn can be resumed without adding another message." } }, "required": [ @@ -7607,6 +7635,9 @@ { "$ref": "#/$defs/ChatTurnStartedAction" }, + { + "$ref": "#/$defs/ChatTurnResumedAction" + }, { "$ref": "#/$defs/ChatDeltaAction" }, diff --git a/schema/commands.schema.json b/schema/commands.schema.json index e65cb0f5..e6c2f403 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -4426,6 +4426,10 @@ "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details if state is `'error'`" + }, + "resumable": { + "type": "boolean", + "description": "Whether this failed turn can be resumed without adding another message." } }, "required": [ @@ -7085,6 +7089,23 @@ "message" ] }, + "ChatTurnResumedAction": { + "type": "object", + "description": "Resumes a failed turn without adding another message.", + "properties": { + "type": { + "const": "chat/turnResumed" + }, + "turnId": { + "type": "string", + "description": "Identifier of the resumable failed turn." + } + }, + "required": [ + "type", + "turnId" + ] + }, "ChatDeltaAction": { "type": "object", "description": "Streaming text chunk from the assistant, appended to a specific response part.\n\nThe server MUST first emit a `chat/responsePart` to create the target\npart (markdown or reasoning), then use this action to append text to it.", @@ -7640,6 +7661,10 @@ "$ref": "#/$defs/ErrorInfo", "description": "Error details" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed turn can be resumed without adding another message." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -8567,6 +8592,9 @@ { "$ref": "#/$defs/ChatTurnStartedAction" }, + { + "$ref": "#/$defs/ChatTurnResumedAction" + }, { "$ref": "#/$defs/ChatDeltaAction" }, diff --git a/schema/errors.schema.json b/schema/errors.schema.json index b1b9745f..0d3d1cec 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -3022,6 +3022,10 @@ "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details if state is `'error'`" + }, + "resumable": { + "type": "boolean", + "description": "Whether this failed turn can be resumed without adding another message." } }, "required": [ @@ -7178,6 +7182,9 @@ { "$ref": "#/$defs/ChatTurnStartedAction" }, + { + "$ref": "#/$defs/ChatTurnResumedAction" + }, { "$ref": "#/$defs/ChatDeltaAction" }, @@ -8089,6 +8096,23 @@ "message" ] }, + "ChatTurnResumedAction": { + "type": "object", + "description": "Resumes a failed turn without adding another message.", + "properties": { + "type": { + "const": "chat/turnResumed" + }, + "turnId": { + "type": "string", + "description": "Identifier of the resumable failed turn." + } + }, + "required": [ + "type", + "turnId" + ] + }, "ChatDeltaAction": { "type": "object", "description": "Streaming text chunk from the assistant, appended to a specific response part.\n\nThe server MUST first emit a `chat/responsePart` to create the target\npart (markdown or reasoning), then use this action to append text to it.", @@ -8556,6 +8580,10 @@ "$ref": "#/$defs/ErrorInfo", "description": "Error details" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed turn can be resumed without adding another message." + }, "_meta": { "type": "object", "additionalProperties": {}, diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index ab95e4b4..4c2f35b1 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -3185,6 +3185,10 @@ "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details if state is `'error'`" + }, + "resumable": { + "type": "boolean", + "description": "Whether this failed turn can be resumed without adding another message." } }, "required": [ diff --git a/schema/state.schema.json b/schema/state.schema.json index d594f6b4..49c3b40f 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -2933,6 +2933,10 @@ "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details if state is `'error'`" + }, + "resumable": { + "type": "boolean", + "description": "Whether this failed turn can be resumed without adding another message." } }, "required": [ diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 34e8eabd..4abf1992 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -1378,6 +1378,7 @@ const ACTION_VARIANTS: { { type: 'session/chatUpdated', variantName: 'SessionChatUpdated', tsInterface: 'SessionChatUpdatedAction' }, { type: 'session/defaultChatChanged', variantName: 'SessionDefaultChatChanged', tsInterface: 'SessionDefaultChatChangedAction' }, { type: 'chat/turnStarted', variantName: 'ChatTurnStarted', tsInterface: 'ChatTurnStartedAction' }, + { type: 'chat/turnResumed', variantName: 'ChatTurnResumed', tsInterface: 'ChatTurnResumedAction' }, { type: 'chat/delta', variantName: 'ChatDelta', tsInterface: 'ChatDeltaAction' }, { type: 'chat/responsePart', variantName: 'ChatResponsePart', tsInterface: 'ChatResponsePartAction' }, { type: 'chat/toolCallStart', variantName: 'ChatToolCallStart', tsInterface: 'ChatToolCallStartAction' }, diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 81a192de..96ff4bc7 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -1317,6 +1317,7 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'session/chatUpdated', caseName: 'SessionChatUpdated', tsInterface: 'SessionChatUpdatedAction' }, { type: 'session/defaultChatChanged', caseName: 'SessionDefaultChatChanged', tsInterface: 'SessionDefaultChatChangedAction' }, { type: 'chat/turnStarted', caseName: 'ChatTurnStarted', tsInterface: 'ChatTurnStartedAction' }, + { type: 'chat/turnResumed', caseName: 'ChatTurnResumed', tsInterface: 'ChatTurnResumedAction' }, { type: 'chat/delta', caseName: 'ChatDelta', tsInterface: 'ChatDeltaAction' }, { type: 'chat/responsePart', caseName: 'ChatResponsePart', tsInterface: 'ChatResponsePartAction' }, { type: 'chat/toolCallStart', caseName: 'ChatToolCallStart', tsInterface: 'ChatToolCallStartAction' }, diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 58a3bb9e..891e614a 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -1217,6 +1217,7 @@ const ACTION_VARIANTS: { { type: 'session/chatUpdated', variantName: 'SessionChatUpdated', tsInterface: 'SessionChatUpdatedAction' }, { type: 'session/defaultChatChanged', variantName: 'SessionDefaultChatChanged', tsInterface: 'SessionDefaultChatChangedAction' }, { type: 'chat/turnStarted', variantName: 'ChatTurnStarted', tsInterface: 'ChatTurnStartedAction' }, + { type: 'chat/turnResumed', variantName: 'ChatTurnResumed', tsInterface: 'ChatTurnResumedAction' }, { type: 'chat/delta', variantName: 'ChatDelta', tsInterface: 'ChatDeltaAction' }, { type: 'chat/responsePart', variantName: 'ChatResponsePart', tsInterface: 'ChatResponsePartAction' }, { type: 'chat/toolCallStart', variantName: 'ChatToolCallStart', tsInterface: 'ChatToolCallStartAction' }, diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 6b486893..718873fc 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -1211,6 +1211,7 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'session/chatUpdated', caseName: 'sessionChatUpdated', tsInterface: 'SessionChatUpdatedAction' }, { type: 'session/defaultChatChanged', caseName: 'sessionDefaultChatChanged', tsInterface: 'SessionDefaultChatChangedAction' }, { type: 'chat/turnStarted', caseName: 'chatTurnStarted', tsInterface: 'ChatTurnStartedAction' }, + { type: 'chat/turnResumed', caseName: 'chatTurnResumed', tsInterface: 'ChatTurnResumedAction' }, { type: 'chat/delta', caseName: 'chatDelta', tsInterface: 'ChatDeltaAction' }, { type: 'chat/responsePart', caseName: 'chatResponsePart', tsInterface: 'ChatResponsePartAction' }, { type: 'chat/toolCallStart', caseName: 'chatToolCallStart', tsInterface: 'ChatToolCallStartAction' }, diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index 92cffe48..d70dec75 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -35,6 +35,7 @@ import type { SessionConfigChangedAction, SessionMetaChangedAction, ChatTurnStartedAction, + ChatTurnResumedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, @@ -183,6 +184,7 @@ export type ServerSessionAction = /** Union of all chat-scoped actions. */ export type ChatAction = | ChatTurnStartedAction + | ChatTurnResumedAction | ChatDeltaAction | ChatResponsePartAction | ChatToolCallStartAction @@ -216,6 +218,7 @@ export type ChatAction = /** Union of chat actions that clients may dispatch. */ export type ClientChatAction = | ChatTurnStartedAction + | ChatTurnResumedAction | ChatToolCallConfirmedAction | ChatToolCallCompleteAction | ChatToolCallResultConfirmedAction @@ -389,6 +392,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.SessionConfigChanged]: true, [ActionType.SessionMetaChanged]: false, [ActionType.ChatTurnStarted]: true, + [ActionType.ChatTurnResumed]: true, [ActionType.ChatDelta]: false, [ActionType.ChatResponsePart]: false, [ActionType.ChatToolCallStart]: false, diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index caecaeb1..d3ae43f5 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -86,6 +86,19 @@ export interface ChatTurnStartedAction { _meta?: Record; } +/** + * Resumes a failed turn without adding another message. + * + * @category Chat Actions + * @version 1 + * @clientDispatchable + */ +export interface ChatTurnResumedAction { + type: ActionType.ChatTurnResumed; + /** Identifier of the resumable failed turn. */ + turnId: string; +} + /** * Streaming text chunk from the assistant, appended to a specific response part. * @@ -490,6 +503,8 @@ export interface ChatErrorAction { duration: number; /** Error details */ error: ErrorInfo; + /** Whether the failed turn can be resumed without adding another message. */ + resumable?: boolean; /** * Additional provider-specific metadata for this action. * @@ -807,6 +822,7 @@ export interface ChatInputCompletedAction { export type ChatAction = | ChatTurnStartedAction + | ChatTurnResumedAction | ChatDeltaAction | ChatResponsePartAction | ChatToolCallStartAction diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index b170cefb..7a11d7f4 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -171,6 +171,7 @@ function endTurn( duration: number, terminalStatus?: SessionStatus.Error, error?: { errorType: string; message: string; stack?: string }, + resumable?: boolean, ): ChatState { if (!state.activeTurn || state.activeTurn.id !== turnId) { return state; @@ -209,6 +210,7 @@ function endTurn( usage: active.usage, state: turnState, error, + ...(resumable === true ? { resumable: true } : {}), }; const next: ChatState = { @@ -373,6 +375,38 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st return next; } + case ActionType.ChatTurnResumed: { + if (state.activeTurn) { + return state; + } + const turnIndex = state.turns.length - 1; + const turn = state.turns[turnIndex]; + if (!turn || turn.id !== action.turnId) { + return state; + } + if (turn.state !== TurnState.Error || turn.resumable !== true) { + return state; + } + const turns = state.turns.slice(); + turns.splice(turnIndex, 1); + const next: ChatState = { + ...state, + turns, + activeTurn: { + id: turn.id, + startedAt: turn.startedAt ?? state.modifiedAt, + message: turn.message, + responseParts: turn.responseParts, + usage: turn.usage, + }, + }; + return { + ...next, + status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), + modifiedAt: new Date(Date.now()).toISOString(), + }; + } + case ActionType.ChatDelta: return updateResponsePart(state, action.turnId, action.partId, part => { if (part.kind === ResponsePartKind.Markdown) { @@ -400,7 +434,7 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st return endTurn(state, action.turnId, TurnState.Cancelled, action.duration); case ActionType.ChatError: - return endTurn(state, action.turnId, TurnState.Error, action.duration, SessionStatus.Error, action.error); + return endTurn(state, action.turnId, TurnState.Error, action.duration, SessionStatus.Error, action.error, action.resumable); case ActionType.ChatActivityChanged: return { ...state, activity: action.activity }; diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index 8e107a23..a764453b 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -567,6 +567,8 @@ export interface Turn { state: TurnState; /** Error details if state is `'error'` */ error?: ErrorInfo; + /** Whether this failed turn can be resumed without adding another message. */ + resumable?: boolean; } /** diff --git a/types/common/actions.ts b/types/common/actions.ts index d0716412..6b09b63b 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -47,6 +47,7 @@ import type { import type { ChatTurnStartedAction, + ChatTurnResumedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, @@ -131,6 +132,7 @@ export const enum ActionType { SessionChatUpdated = 'session/chatUpdated', SessionDefaultChatChanged = 'session/defaultChatChanged', ChatTurnStarted = 'chat/turnStarted', + ChatTurnResumed = 'chat/turnResumed', ChatDelta = 'chat/delta', ChatResponsePart = 'chat/responsePart', ChatToolCallStart = 'chat/toolCallStart', @@ -275,6 +277,7 @@ export type StateAction = | SessionConfigChangedAction | SessionMetaChangedAction | ChatTurnStartedAction + | ChatTurnResumedAction | ChatDeltaAction | ChatResponsePartAction | ChatToolCallStartAction diff --git a/types/test-cases/reducers/256-chat-turnresumed-reopens-resumable-error.json b/types/test-cases/reducers/256-chat-turnresumed-reopens-resumable-error.json new file mode 100644 index 00000000..aec0ca6c --- /dev/null +++ b/types/test-cases/reducers/256-chat-turnresumed-reopens-resumable-error.json @@ -0,0 +1,80 @@ +{ + "description": "chat/turnResumed reopens a resumable failed turn with its existing response", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 1000, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "markdown", + "id": "md-1", + "content": "Partial" + } + ], + "usage": { + "inputTokens": 10, + "outputTokens": 2 + }, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Request failed" + }, + "resumable": true + } + ] + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 8, + "modifiedAt": "1970-01-01T00:00:09.999Z", + "origin": { + "kind": "user" + }, + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "markdown", + "id": "md-1", + "content": "Partial" + } + ], + "usage": { + "inputTokens": 10, + "outputTokens": 2 + } + } + } +} diff --git a/types/test-cases/reducers/257-chat-turnresumed-completes-one-turn.json b/types/test-cases/reducers/257-chat-turnresumed-completes-one-turn.json new file mode 100644 index 00000000..3e2bc4f2 --- /dev/null +++ b/types/test-cases/reducers/257-chat-turnresumed-completes-one-turn.json @@ -0,0 +1,98 @@ +{ + "description": "a resumed turn completes as one durable turn", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 1000, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "markdown", + "id": "md-1", + "content": "Partial" + } + ], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Request failed" + }, + "resumable": true + } + ] + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + }, + { + "type": "chat/responsePart", + "turnId": "turn-1", + "part": { + "kind": "markdown", + "id": "md-2", + "content": "Done" + } + }, + { + "type": "chat/turnComplete", + "turnId": "turn-1", + "duration": 2000 + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:09.999Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 2000, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "markdown", + "id": "md-1", + "content": "Partial" + }, + { + "kind": "markdown", + "id": "md-2", + "content": "Done" + } + ], + "usage": null, + "state": "complete", + "error": null + } + ], + "activeTurn": null + } +} diff --git a/types/test-cases/reducers/258-chat-turnresumed-noop-with-active-turn.json b/types/test-cases/reducers/258-chat-turnresumed-noop-with-active-turn.json new file mode 100644 index 00000000..825c7aa3 --- /dev/null +++ b/types/test-cases/reducers/258-chat-turnresumed-noop-with-active-turn.json @@ -0,0 +1,54 @@ +{ + "description": "chat/turnResumed is a no-op while another turn is active", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [], + "activeTurn": { + "id": "turn-active", + "startedAt": "1970-01-01T00:00:02.000Z", + "message": { + "text": "Running", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + } + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [], + "activeTurn": { + "id": "turn-active", + "startedAt": "1970-01-01T00:00:02.000Z", + "message": { + "text": "Running", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + } + } +} diff --git a/types/test-cases/reducers/259-chat-turnresumed-noop-for-unknown-turn.json b/types/test-cases/reducers/259-chat-turnresumed-noop-for-unknown-turn.json new file mode 100644 index 00000000..ac31d782 --- /dev/null +++ b/types/test-cases/reducers/259-chat-turnresumed-noop-for-unknown-turn.json @@ -0,0 +1,30 @@ +{ + "description": "chat/turnResumed is a no-op for an unknown turn", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [] + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [] + } +} diff --git a/types/test-cases/reducers/260-chat-turnresumed-noop-for-completed-turn.json b/types/test-cases/reducers/260-chat-turnresumed-noop-for-completed-turn.json new file mode 100644 index 00000000..83e20470 --- /dev/null +++ b/types/test-cases/reducers/260-chat-turnresumed-noop-for-completed-turn.json @@ -0,0 +1,56 @@ +{ + "description": "chat/turnResumed is a no-op for a completed turn", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ] + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ] + } +} diff --git a/types/test-cases/reducers/261-chat-turnresumed-noop-for-nonresumable-error.json b/types/test-cases/reducers/261-chat-turnresumed-noop-for-nonresumable-error.json new file mode 100644 index 00000000..ca8fd2f5 --- /dev/null +++ b/types/test-cases/reducers/261-chat-turnresumed-noop-for-nonresumable-error.json @@ -0,0 +1,64 @@ +{ + "description": "chat/turnResumed is a no-op for a non-resumable error", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Request failed" + } + } + ] + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Request failed" + } + } + ] + } +} diff --git a/types/test-cases/reducers/262-chat-turnresumed-can-resume-again-after-error.json b/types/test-cases/reducers/262-chat-turnresumed-can-resume-again-after-error.json new file mode 100644 index 00000000..2a1ac0c4 --- /dev/null +++ b/types/test-cases/reducers/262-chat-turnresumed-can-resume-again-after-error.json @@ -0,0 +1,74 @@ +{ + "description": "a resumed turn can be resumed again after another resumable error", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "First failure" + }, + "resumable": true + } + ] + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + }, + { + "type": "chat/error", + "turnId": "turn-1", + "duration": 1000, + "error": { + "errorType": "runtime", + "message": "Second failure" + }, + "resumable": true + }, + { + "type": "chat/turnResumed", + "turnId": "turn-1" + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 8, + "modifiedAt": "1970-01-01T00:00:09.999Z", + "origin": { + "kind": "user" + }, + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:02.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + } + } +} diff --git a/types/test-cases/reducers/263-chat-turnresumed-noop-for-nonlatest-error.json b/types/test-cases/reducers/263-chat-turnresumed-noop-for-nonlatest-error.json new file mode 100644 index 00000000..a88ec2b8 --- /dev/null +++ b/types/test-cases/reducers/263-chat-turnresumed-noop-for-nonlatest-error.json @@ -0,0 +1,86 @@ +{ + "description": "chat/turnResumed is a no-op for a resumable error that is not the most recent turn", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:04.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "state": "error", + "error": { + "errorType": "runtime", + "message": "Request failed" + }, + "resumable": true + }, + { + "id": "turn-2", + "message": { + "text": "Second", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "state": "complete" + } + ] + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:04.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "state": "error", + "error": { + "errorType": "runtime", + "message": "Request failed" + }, + "resumable": true + }, + { + "id": "turn-2", + "message": { + "text": "Second", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "state": "complete" + } + ] + } +} diff --git a/types/version/registry.ts b/types/version/registry.ts index 205e00e8..4fe29b89 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -109,6 +109,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.SessionConfigChanged]: '0.1.0', [ActionType.SessionMetaChanged]: '0.1.0', [ActionType.ChatTurnStarted]: '0.4.0', + [ActionType.ChatTurnResumed]: '0.8.0', [ActionType.ChatDelta]: '0.4.0', [ActionType.ChatResponsePart]: '0.4.0', [ActionType.ChatToolCallStart]: '0.4.0',