diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 978608c9..7c145831 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -491,6 +491,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 { @@ -1020,6 +1022,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.Error == nil || turn.Error.Resumable == nil || !*turn.Error.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 4071bd50..3f2c4d49 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 @@ -1501,6 +1509,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 +1660,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 e0618669..f089961c 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -593,6 +593,8 @@ type AgentCapabilities struct { // set and MUST NOT set more than one entry in // {@link CreateSessionParams.workingDirectories}. MultipleWorkingDirectories *MultipleWorkingDirectoriesCapability `json:"multipleWorkingDirectories,omitempty"` + // The agent can resume a failed turn without adding another message. + ResumeTurn *json.RawMessage `json:"resumeTurn,omitempty"` } // Options for the {@link AgentCapabilities.multipleChats} capability. @@ -3183,6 +3185,8 @@ type ErrorInfo struct { Message string `json:"message"` // Stack trace Stack *string `json:"stack,omitempty"` + // Whether the failed operation can be resumed without adding new user input. + Resumable *bool `json:"resumable,omitempty"` // Additional provider-specific metadata for this error. // Clients MAY look for well-known optional keys here to provide enhanced UI // (e.g. a structured chat fetch error for richer, localized messaging). 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 b62d7a61..d6b71a8a 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -844,6 +844,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.error?.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 -> 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 f696130b..0e1c5cd3 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, @@ -1546,6 +1557,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 +1658,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 +1752,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 840fe331..b04e7e1f 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 @@ -962,7 +962,11 @@ data class AgentCapabilities( * set and MUST NOT set more than one entry in * {@link CreateSessionParams.workingDirectories}. */ - val multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = null + val multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = null, + /** + * The agent can resume a failed turn without adding another message. + */ + val resumeTurn: JsonElement? = null ) @Serializable @@ -4251,6 +4255,10 @@ data class ErrorInfo( * Stack trace */ val stack: String? = null, + /** + * Whether the failed operation can be resumed without adding new user input. + */ + val resumable: Boolean? = null, /** * Additional provider-specific metadata for this error. * Clients MAY look for well-known optional keys here to provide enhanced UI diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 8cd2c8f4..7240a614 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 @@ -1798,6 +1808,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 bd978751..7103702a 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -804,6 +804,9 @@ pub struct AgentCapabilities { /// {@link CreateSessionParams.workingDirectories}. #[serde(default, skip_serializing_if = "Option::is_none")] pub multiple_working_directories: Option, + /// The agent can resume a failed turn without adding another message. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resume_turn: Option, } /// Options for the {@link AgentCapabilities.multipleChats} capability. @@ -3837,6 +3840,9 @@ pub struct ErrorInfo { /// Stack trace #[serde(default, skip_serializing_if = "Option::is_none")] pub stack: Option, + /// Whether the failed operation can be resumed without adding new user input. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resumable: Option, /// Additional provider-specific metadata for this error. /// Clients MAY look for well-known optional keys here to provide enhanced UI /// (e.g. a structured chat fetch error for richer, localized messaging). diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 59c62518..a8b86215 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, @@ -943,6 +943,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); @@ -1236,6 +1237,34 @@ 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.error.as_ref().and_then(|error| error.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 809dd13b..cb57b482 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 @@ -2026,6 +2041,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 +2148,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 +2318,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 515f9c4a..c07f8155 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -635,13 +635,17 @@ public struct AgentCapabilities: Codable, Sendable { /// set and MUST NOT set more than one entry in /// {@link CreateSessionParams.workingDirectories}. public var multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? + /// The agent can resume a failed turn without adding another message. + public var resumeTurn: AnyCodable? public init( multipleChats: MultipleChatsCapability? = nil, - multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = nil + multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = nil, + resumeTurn: AnyCodable? = nil ) { self.multipleChats = multipleChats self.multipleWorkingDirectories = multipleWorkingDirectories + self.resumeTurn = resumeTurn } } @@ -4741,6 +4745,8 @@ public struct ErrorInfo: Codable, Sendable { public var message: String /// Stack trace public var stack: String? + /// Whether the failed operation can be resumed without adding new user input. + public var resumable: Bool? /// Additional provider-specific metadata for this error. /// Clients MAY look for well-known optional keys here to provide enhanced UI /// (e.g. a structured chat fetch error for richer, localized messaging). @@ -4750,6 +4756,7 @@ public struct ErrorInfo: Codable, Sendable { case errorType case message case stack + case resumable case meta = "_meta" } @@ -4757,11 +4764,13 @@ public struct ErrorInfo: Codable, Sendable { errorType: String, message: String, stack: String? = nil, + resumable: Bool? = nil, meta: [String: AnyCodable]? = nil ) { self.errorType = errorType self.message = message self.stack = stack + self.resumable = resumable self.meta = meta } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index fc0f03fb..f239d88a 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -139,6 +139,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.error?.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 } diff --git a/docs/.changes/20260717-resume-failed-turn.json b/docs/.changes/20260717-resume-failed-turn.json new file mode 100644 index 00000000..94ef9cdf --- /dev/null +++ b/docs/.changes/20260717-resume-failed-turn.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "`chat/turnResumed`, the `resumeTurn` agent capability, and resumable turn errors let clients continue failed turns without adding another user message." +} diff --git a/docs/guide/actions.md b/docs/guide/actions.md index 8f08f3d0..aca8d9fe 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 a2c8d020..9ead36d0 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 `error.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 9a046769..7158d9e0 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -711,6 +711,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.", @@ -2109,6 +2126,9 @@ { "$ref": "#/$defs/ChatTurnStartedAction" }, + { + "$ref": "#/$defs/ChatTurnResumedAction" + }, { "$ref": "#/$defs/ChatDeltaAction" }, @@ -2567,6 +2587,10 @@ "type": "string", "description": "Stack trace" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed operation can be resumed without adding new user input." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -2718,6 +2742,11 @@ "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary}\n(some backends pin their first directory as a fixed process root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." + }, + "resumeTurn": { + "type": "object", + "properties": {}, + "description": "The agent can resume a failed turn without adding another message." } } }, @@ -7604,6 +7633,9 @@ { "$ref": "#/$defs/ChatTurnStartedAction" }, + { + "$ref": "#/$defs/ChatTurnResumedAction" + }, { "$ref": "#/$defs/ChatDeltaAction" }, diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 775ee128..90f9f702 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -1730,6 +1730,10 @@ "type": "string", "description": "Stack trace" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed operation can be resumed without adding new user input." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -1881,6 +1885,11 @@ "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary}\n(some backends pin their first directory as a fixed process root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." + }, + "resumeTurn": { + "type": "object", + "properties": {}, + "description": "The agent can resume a failed turn without adding another message." } } }, @@ -6951,6 +6960,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.", @@ -8435,6 +8461,9 @@ { "$ref": "#/$defs/ChatTurnStartedAction" }, + { + "$ref": "#/$defs/ChatTurnResumedAction" + }, { "$ref": "#/$defs/ChatDeltaAction" }, diff --git a/schema/errors.schema.json b/schema/errors.schema.json index c770002f..9e6adcdc 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -466,6 +466,10 @@ "type": "string", "description": "Stack trace" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed operation can be resumed without adding new user input." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -617,6 +621,11 @@ "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary}\n(some backends pin their first directory as a fixed process root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." + }, + "resumeTurn": { + "type": "object", + "properties": {}, + "description": "The agent can resume a failed turn without adding another message." } } }, @@ -7030,6 +7039,9 @@ { "$ref": "#/$defs/ChatTurnStartedAction" }, + { + "$ref": "#/$defs/ChatTurnResumedAction" + }, { "$ref": "#/$defs/ChatDeltaAction" }, @@ -7944,6 +7956,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.", diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index a11260fd..f868fa33 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -629,6 +629,10 @@ "type": "string", "description": "Stack trace" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed operation can be resumed without adding new user input." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -780,6 +784,11 @@ "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary}\n(some backends pin their first directory as a fixed process root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." + }, + "resumeTurn": { + "type": "object", + "properties": {}, + "description": "The agent can resume a failed turn without adding another message." } } }, diff --git a/schema/state.schema.json b/schema/state.schema.json index 0d347e2e..f17de0ea 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -377,6 +377,10 @@ "type": "string", "description": "Stack trace" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed operation can be resumed without adding new user input." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -528,6 +532,11 @@ "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary}\n(some backends pin their first directory as a fixed process root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." + }, + "resumeTurn": { + "type": "object", + "properties": {}, + "description": "The agent can resume a failed turn without adding another message." } } }, diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 33d7009c..691a15ac 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -1341,6 +1341,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 4fad6f13..aab57967 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -1270,6 +1270,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 eaba7592..f021e6b4 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -1203,6 +1203,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 88d307d6..e1da37d8 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -1181,6 +1181,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 35307ec1..bb671c64 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -85,6 +85,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. * @@ -800,6 +813,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 332eaaec..ce5b7f69 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -373,6 +373,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.error?.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) { diff --git a/types/channels-root/state.ts b/types/channels-root/state.ts index eaceed68..30a6bc00 100644 --- a/types/channels-root/state.ts +++ b/types/channels-root/state.ts @@ -122,6 +122,8 @@ export interface AgentCapabilities { * {@link CreateSessionParams.workingDirectories}. */ multipleWorkingDirectories?: MultipleWorkingDirectoriesCapability; + /** The agent can resume a failed turn without adding another message. */ + resumeTurn?: {}; } /** 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/common/state.ts b/types/common/state.ts index be6c2e16..0660f4ec 100644 --- a/types/common/state.ts +++ b/types/common/state.ts @@ -315,6 +315,8 @@ export interface ErrorInfo { message: string; /** Stack trace */ stack?: string; + /** Whether the failed operation can be resumed without adding new user input. */ + resumable?: boolean; /** * Additional provider-specific metadata for this error. * Clients MAY look for well-known optional keys here to provide enhanced UI 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..78fa9da7 --- /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..708205f8 --- /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..7840e5de --- /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..1bc4673a --- /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 e2ea8314..4259143d 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -108,6 +108,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.7.0', [ActionType.ChatDelta]: '0.4.0', [ActionType.ChatResponsePart]: '0.4.0', [ActionType.ChatToolCallStart]: '0.4.0',