diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 9d2a34161..7614f267d 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -190,6 +190,14 @@ func hasOpenInputRequest(state *ahptypes.ChatState) bool { return false } +func hasResumableError(turn *ahptypes.Turn) bool { + if len(turn.ResponseParts) == 0 { + return false + } + part, ok := turn.ResponseParts[len(turn.ResponseParts)-1].Value.(*ahptypes.ErrorResponsePart) + return ok && part.Resumable != nil && *part.Resumable +} + func summaryStatus(state *ahptypes.ChatState, terminal *ahptypes.SessionStatus) ahptypes.SessionStatus { var activity ahptypes.SessionStatus switch { @@ -215,7 +223,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, errorPart *ahptypes.ErrorResponsePart) ReduceOutcome { if state.ActiveTurn == nil || state.ActiveTurn.Id != turnID { return ReduceOutcomeNoOp } @@ -253,6 +261,9 @@ func endTurn(state *ahptypes.ChatState, turnID string, duration int64, turnState ToolCall: ahptypes.ToolCallState{Value: cancelled}, }}) } + if errorPart != nil { + parts = append(parts, ahptypes.ResponsePart{Value: errorPart}) + } // Defensive clamp: duration is producer-supplied and opaque to this // reducer, but a negative value would be nonsensical to display. @@ -268,7 +279,6 @@ func endTurn(state *ahptypes.ChatState, turnID string, duration int64, turnState ResponseParts: parts, Usage: active.Usage, State: turnState, - Error: errInfo, } state.Turns = append(state.Turns, turn) @@ -442,6 +452,7 @@ func updateResponsePart(state *ahptypes.ChatState, turnID, partID string, update if state.ActiveTurn == nil || state.ActiveTurn.Id != turnID { return ReduceOutcomeNoOp } + for i := range state.ActiveTurn.ResponseParts { part := &state.ActiveTurn.ResponseParts[i] var id string @@ -515,6 +526,9 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R if state.ActiveTurn == nil || state.ActiveTurn.Id != a.TurnId { return ReduceOutcomeNoOp } + if _, ok := a.Part.Value.(*ahptypes.ErrorResponsePart); ok { + return ReduceOutcomeNoOp + } state.ActiveTurn.ResponseParts = append(state.ActiveTurn.ResponseParts, a.Part) return ReduceOutcomeApplied case *ahptypes.ChatTurnCompleteAction: @@ -522,9 +536,33 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R case *ahptypes.ChatTurnCancelledAction: return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateCancelled, nil, nil) case *ahptypes.ChatErrorAction: - errCopy := a.Error errStatus := ahptypes.SessionStatusError - return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateError, &errStatus, &errCopy) + return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateError, &errStatus, &a.Part) + case *ahptypes.ChatTurnResumeAction: + 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 || !hasResumableError(&turn) { + 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 = withStatusFlag(summaryStatus(state, nil), ahptypes.SessionStatusIsRead, false) + touchChatModified(state) + return ReduceOutcomeApplied case *ahptypes.ChatActivityChangedAction: state.Activity = a.Activity return ReduceOutcomeApplied diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index fccdb904b..288e742d8 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -42,6 +42,7 @@ const ( ActionTypeChatTurnComplete ActionType = "chat/turnComplete" ActionTypeChatTurnCancelled ActionType = "chat/turnCancelled" ActionTypeChatError ActionType = "chat/error" + ActionTypeChatTurnResume ActionType = "chat/turnResume" ActionTypeChatActivityChanged ActionType = "chat/activityChanged" ActionTypeChatWorkingDirectorySet ActionType = "chat/workingDirectorySet" ActionTypeChatWorkingDirectoryRemoved ActionType = "chat/workingDirectoryRemoved" @@ -257,11 +258,14 @@ type ChatDeltaAction struct { } // Structured content appended to the response. +// +// An {@link ErrorResponsePart} MUST be appended with {@link ChatErrorAction} +// instead so adding the part and ending the turn are one atomic transition. type ChatResponsePartAction struct { Type ActionType `json:"type"` // Turn identifier TurnId string `json:"turnId"` - // Response part (markdown or content ref) + // Response part to append; error parts are ignored. Part ResponsePart `json:"part"` // Additional provider-specific metadata for this action. // @@ -589,8 +593,9 @@ type ChatErrorAction struct { // client clocks may differ — and MUST treat it as opaque, producer-supplied // data. Duration int64 `json:"duration"` - // Error details - Error ErrorInfo `json:"error"` + // Error part to append to the response stream before finalizing the turn. + // Its optional `resumable` flag indicates whether the turn can be resumed. + Part ErrorResponsePart `json:"part"` // Additional provider-specific metadata for this action. // // Clients MAY look for well-known keys here to provide enhanced UI, and @@ -601,6 +606,18 @@ type ChatErrorAction struct { Meta map[string]json.RawMessage `json:"_meta,omitempty"` } +// Resumes the latest errored turn without adding another message. +// +// The turn MUST be the latest turn, its state MUST be `error`, and its final +// response part MUST be a resumable error. The reducer reopens the same turn +// with its existing message, response parts, and usage intact. The host then +// resumes the provider's execution for that turn. +type ChatTurnResumeAction struct { + Type ActionType `json:"type"` + // Identifier of the errored turn. + TurnId string `json:"turnId"` +} + // The activity description of this chat changed. // // Dispatched by the server to indicate what the chat is currently doing @@ -1515,6 +1532,7 @@ func (*ChatToolCallAuthResolvedAction) isStateAction() {} func (*ChatTurnCompleteAction) isStateAction() {} func (*ChatTurnCancelledAction) isStateAction() {} func (*ChatErrorAction) isStateAction() {} +func (*ChatTurnResumeAction) isStateAction() {} func (*ChatActivityChangedAction) isStateAction() {} func (*SessionTitleChangedAction) isStateAction() {} func (*ChatUsageAction) isStateAction() {} @@ -1735,6 +1753,12 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "chat/turnResume": + var value ChatTurnResumeAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value case "chat/activityChanged": var value ChatActivityChangedAction 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 99693a7bb..c331d31db 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -216,6 +216,7 @@ const ( ResponsePartKindReasoning ResponsePartKind = "reasoning" ResponsePartKindSystemNotification ResponsePartKind = "systemNotification" ResponsePartKindInputRequest ResponsePartKind = "inputRequest" + ResponsePartKindError ResponsePartKind = "error" ) // Status of a tool call in the lifecycle state machine. @@ -1285,8 +1286,6 @@ type Turn struct { Usage *UsageInfo `json:"usage,omitempty"` // How the turn ended State TurnState `json:"state"` - // Error details if state is `'error'` - Error *ErrorInfo `json:"error,omitempty"` } // An in-progress turn — the assistant is actively streaming. @@ -1858,6 +1857,24 @@ type InputRequestResponsePart struct { Response *ChatInputResponseKind `json:"response,omitempty"` } +// An error encountered while processing a turn. +// +// This is the detailed source of truth for the error. {@link Turn.state} +// remains {@link TurnState.Error} while the turn is stopped at this error so +// clients can detect the terminal state without inspecting response parts. +// +// When {@link resumable} is `true`, a client may dispatch `chat/turnResume` +// while this is the latest turn and its state is {@link TurnState.Error}. +// Clients decide whether and how to present that affordance. +type ErrorResponsePart struct { + // Discriminant + Kind ResponsePartKind `json:"kind"` + // Error details. + Error ErrorInfo `json:"error"` + // Whether the host can resume the turn from this error. Only `true` enables resume. + Resumable *bool `json:"resumable,omitempty"` +} + // Tool execution result details, available after execution completes. type ToolCallResult struct { // Whether the tool succeeded @@ -3605,6 +3622,7 @@ func (*ToolCallResponsePart) isResponsePart() {} func (*ReasoningResponsePart) isResponsePart() {} func (*SystemNotificationResponsePart) isResponsePart() {} func (*InputRequestResponsePart) isResponsePart() {} +func (*ErrorResponsePart) isResponsePart() {} // ResponsePartUnknown carries an unrecognized ResponsePart variant — typically a discriminator value introduced by a newer protocol version. The original JSON object is preserved verbatim so that re-encoding round-trips faithfully. type ResponsePartUnknown struct { @@ -3656,6 +3674,12 @@ func (u *ResponsePart) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "error": + var value ErrorResponsePart + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value default: raw := make(json.RawMessage, len(data)) copy(raw, data) 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 6222ed855..863ff16d1 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -378,7 +378,7 @@ private fun endTurn( duration: Long, turnState: TurnState, terminalStatus: SessionStatus? = null, - error: ErrorInfo? = null, + errorPart: ErrorResponsePart? = null, ): ChatState { val active = state.activeTurn ?: return state if (active.id != turnId) return state @@ -422,6 +422,11 @@ private fun endTurn( ), ) } + val responseParts = if (errorPart == null) { + finalizedParts + } else { + finalizedParts + ResponsePartError(errorPart) + } // Defensive clamp: `duration` is producer-supplied and opaque to this // reducer, but a negative value would be nonsensical to display. @@ -430,10 +435,9 @@ private fun endTurn( startedAt = active.startedAt, duration = maxOf(0L, duration), message = active.message, - responseParts = finalizedParts, + responseParts = responseParts, usage = active.usage, state = turnState, - error = error, ) val withoutTurn = state.copy( @@ -863,7 +867,7 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when is StateActionChatResponsePart -> { val a = action.value val activeTurn = state.activeTurn - if (activeTurn == null || activeTurn.id != a.turnId) { + if (activeTurn == null || activeTurn.id != a.turnId || a.part is ResponsePartError) { state } else { state.copy( @@ -879,7 +883,40 @@ 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.part) + + is StateActionChatTurnResume -> { + val a = action.value + if (state.activeTurn != null || state.turns.isEmpty()) { + state + } else { + val turnIndex = state.turns.lastIndex + val turn = state.turns[turnIndex] + if (turn.id != a.turnId || turn.state != TurnState.ERROR) { + state + } else { + val errorPart = turn.responseParts.lastOrNull() as? ResponsePartError + if (errorPart?.value?.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 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 cde9f9ede..d20a379b4 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 @@ -72,6 +72,8 @@ enum class ActionType { CHAT_TURN_CANCELLED, @SerialName("chat/error") CHAT_ERROR, + @SerialName("chat/turnResume") + CHAT_TURN_RESUME, @SerialName("chat/activityChanged") CHAT_ACTIVITY_CHANGED, @SerialName("chat/workingDirectorySet") @@ -363,7 +365,7 @@ data class ChatResponsePartAction( */ val turnId: String, /** - * Response part (markdown or content ref) + * Response part to append; error parts are ignored. */ val part: ResponsePart, /** @@ -751,9 +753,10 @@ data class ChatErrorAction( */ val duration: Long, /** - * Error details + * Error part to append to the response stream before finalizing the turn. + * Its optional `resumable` flag indicates whether the turn can be resumed. */ - val error: ErrorInfo, + val part: ErrorResponsePart, /** * Additional provider-specific metadata for this action. * @@ -767,6 +770,15 @@ data class ChatErrorAction( val meta: Map? = null ) +@Serializable +data class ChatTurnResumeAction( + val type: ActionType, + /** + * Identifier of the errored turn. + */ + val turnId: String +) + @Serializable data class ChatActivityChangedAction( val type: ActionType, @@ -1560,6 +1572,7 @@ sealed interface StateAction @JvmInline value class StateActionChatTurnComplete(val value: ChatTurnCompleteAction) : StateAction @JvmInline value class StateActionChatTurnCancelled(val value: ChatTurnCancelledAction) : StateAction @JvmInline value class StateActionChatError(val value: ChatErrorAction) : StateAction +@JvmInline value class StateActionChatTurnResume(val value: ChatTurnResumeAction) : StateAction @JvmInline value class StateActionChatActivityChanged(val value: ChatActivityChangedAction) : StateAction @JvmInline value class StateActionSessionTitleChanged(val value: SessionTitleChangedAction) : StateAction @JvmInline value class StateActionChatUsage(val value: ChatUsageAction) : StateAction @@ -1660,6 +1673,7 @@ internal object StateActionSerializer : KSerializer { "chat/turnComplete" -> StateActionChatTurnComplete(input.json.decodeFromJsonElement(ChatTurnCompleteAction.serializer(), element)) "chat/turnCancelled" -> StateActionChatTurnCancelled(input.json.decodeFromJsonElement(ChatTurnCancelledAction.serializer(), element)) "chat/error" -> StateActionChatError(input.json.decodeFromJsonElement(ChatErrorAction.serializer(), element)) + "chat/turnResume" -> StateActionChatTurnResume(input.json.decodeFromJsonElement(ChatTurnResumeAction.serializer(), element)) "chat/activityChanged" -> StateActionChatActivityChanged(input.json.decodeFromJsonElement(ChatActivityChangedAction.serializer(), element)) "session/titleChanged" -> StateActionSessionTitleChanged(input.json.decodeFromJsonElement(SessionTitleChangedAction.serializer(), element)) "chat/usage" -> StateActionChatUsage(input.json.decodeFromJsonElement(ChatUsageAction.serializer(), element)) @@ -1753,6 +1767,7 @@ internal object StateActionSerializer : KSerializer { is StateActionChatTurnComplete -> output.json.encodeToJsonElement(ChatTurnCompleteAction.serializer(), value.value) is StateActionChatTurnCancelled -> output.json.encodeToJsonElement(ChatTurnCancelledAction.serializer(), value.value) is StateActionChatError -> output.json.encodeToJsonElement(ChatErrorAction.serializer(), value.value) + is StateActionChatTurnResume -> output.json.encodeToJsonElement(ChatTurnResumeAction.serializer(), value.value) is StateActionChatActivityChanged -> output.json.encodeToJsonElement(ChatActivityChangedAction.serializer(), value.value) is StateActionSessionTitleChanged -> output.json.encodeToJsonElement(SessionTitleChangedAction.serializer(), value.value) is StateActionChatUsage -> output.json.encodeToJsonElement(ChatUsageAction.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 d6fe2b4e6..bc8e73ccb 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 @@ -409,7 +409,9 @@ enum class ResponsePartKind { @SerialName("systemNotification") SYSTEM_NOTIFICATION, @SerialName("inputRequest") - INPUT_REQUEST + INPUT_REQUEST, + @SerialName("error") + ERROR } /** @@ -1730,11 +1732,7 @@ data class Turn( /** * How the turn ended */ - val state: TurnState, - /** - * Error details if state is `'error'` - */ - val error: ErrorInfo? = null + val state: TurnState ) @Serializable @@ -2511,6 +2509,22 @@ data class InputRequestResponsePart( val response: ChatInputResponseKind? = null ) +@Serializable +data class ErrorResponsePart( + /** + * Discriminant + */ + val kind: ResponsePartKind, + /** + * Error details. + */ + val error: ErrorInfo, + /** + * Whether the host can resume the turn from this error. Only `true` enables resume. + */ + val resumable: Boolean? = null +) + @Serializable data class ToolCallResult( /** @@ -4785,6 +4799,8 @@ value class ResponsePartReasoning(val value: ReasoningResponsePart) : ResponsePa value class ResponsePartSystemNotification(val value: SystemNotificationResponsePart) : ResponsePart @JvmInline value class ResponsePartInputRequest(val value: InputRequestResponsePart) : ResponsePart +@JvmInline +value class ResponsePartError(val value: ErrorResponsePart) : ResponsePart /** * Forward-compat catch-all for unknown ResponsePart discriminators. * @@ -4815,6 +4831,7 @@ internal object ResponsePartSerializer : KSerializer { "reasoning" -> ResponsePartReasoning(input.json.decodeFromJsonElement(ReasoningResponsePart.serializer(), element)) "systemNotification" -> ResponsePartSystemNotification(input.json.decodeFromJsonElement(SystemNotificationResponsePart.serializer(), element)) "inputRequest" -> ResponsePartInputRequest(input.json.decodeFromJsonElement(InputRequestResponsePart.serializer(), element)) + "error" -> ResponsePartError(input.json.decodeFromJsonElement(ErrorResponsePart.serializer(), element)) else -> ResponsePartUnknown(obj) } } @@ -4829,6 +4846,7 @@ internal object ResponsePartSerializer : KSerializer { is ResponsePartReasoning -> output.json.encodeToJsonElement(ReasoningResponsePart.serializer(), value.value) is ResponsePartSystemNotification -> output.json.encodeToJsonElement(SystemNotificationResponsePart.serializer(), value.value) is ResponsePartInputRequest -> output.json.encodeToJsonElement(InputRequestResponsePart.serializer(), value.value) + is ResponsePartError -> output.json.encodeToJsonElement(ErrorResponsePart.serializer(), value.value) is ResponsePartUnknown -> value.raw } output.encodeJsonElement(element) diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 849a339c5..5e5cfe0c0 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -16,11 +16,12 @@ use crate::state::{ AgentInfo, AgentSelection, Annotation, AnnotationEntry, Changeset, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ChatSummary, - ConfirmationOption, ContentRef, Customization, ErrorInfo, McpAuthRequirement, McpServerState, - Message, ModelSelection, PendingMessageKind, ResponsePart, SessionActiveClient, - SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, - ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributor, ToolCallResult, - ToolCallRiskAssessment, ToolDefinition, ToolInput, ToolResultContent, Turn, UsageInfo, + ConfirmationOption, ContentRef, Customization, ErrorInfo, ErrorResponsePart, + McpAuthRequirement, McpServerState, Message, ModelSelection, PendingMessageKind, ResponsePart, + SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, + TextRange, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributor, + ToolCallResult, ToolCallRiskAssessment, ToolDefinition, ToolInput, ToolResultContent, Turn, + UsageInfo, }; // ─── ActionType ────────────────────────────────────────────────────── @@ -74,6 +75,8 @@ pub enum ActionType { ChatTurnCancelled, #[serde(rename = "chat/error")] ChatError, + #[serde(rename = "chat/turnResume")] + ChatTurnResume, #[serde(rename = "chat/activityChanged")] ChatActivityChanged, #[serde(rename = "chat/workingDirectorySet")] @@ -377,12 +380,15 @@ pub struct ChatDeltaAction { } /// Structured content appended to the response. +/// +/// An {@link ErrorResponsePart} MUST be appended with {@link ChatErrorAction} +/// instead so adding the part and ending the turn are one atomic transition. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatResponsePartAction { /// Turn identifier pub turn_id: String, - /// Response part (markdown or content ref) + /// Response part to append; error parts are ignored. pub part: ResponsePart, /// Additional provider-specific metadata for this action. /// @@ -751,7 +757,7 @@ pub struct ChatTurnCancelledAction { } /// Error during turn processing. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatErrorAction { /// Turn identifier @@ -761,8 +767,9 @@ pub struct ChatErrorAction { /// client clocks may differ — and MUST treat it as opaque, producer-supplied /// data. pub duration: i64, - /// Error details - pub error: ErrorInfo, + /// Error part to append to the response stream before finalizing the turn. + /// Its optional `resumable` flag indicates whether the turn can be resumed. + pub part: ErrorResponsePart, /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -774,6 +781,54 @@ pub struct ChatErrorAction { pub meta: Option, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ChatErrorActionPart<'a> { + kind: &'static str, + error: &'a ErrorInfo, + #[serde(skip_serializing_if = "Option::is_none")] + resumable: Option, +} + +impl Serialize for ChatErrorAction { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + + let mut state = serializer + .serialize_struct("ChatErrorAction", if self.meta.is_some() { 4 } else { 3 })?; + state.serialize_field("turnId", &self.turn_id)?; + state.serialize_field("duration", &self.duration)?; + state.serialize_field( + "part", + &ChatErrorActionPart { + kind: "error", + error: &self.part.error, + resumable: self.part.resumable, + }, + )?; + if let Some(meta) = &self.meta { + state.serialize_field("_meta", meta)?; + } + state.end() + } +} + +/// Resumes the latest errored turn without adding another message. +/// +/// The turn MUST be the latest turn, its state MUST be `error`, and its final +/// response part MUST be a resumable error. The reducer reopens the same turn +/// with its existing message, response parts, and usage intact. The host then +/// resumes the provider's execution for that turn. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatTurnResumeAction { + /// Identifier of the errored turn. + pub turn_id: String, +} + /// The activity description of this chat changed. /// /// Dispatched by the server to indicate what the chat is currently doing @@ -1827,6 +1882,8 @@ pub enum StateAction { ChatTurnCancelled(ChatTurnCancelledAction), #[serde(rename = "chat/error")] ChatError(ChatErrorAction), + #[serde(rename = "chat/turnResume")] + ChatTurnResume(ChatTurnResumeAction), #[serde(rename = "chat/activityChanged")] ChatActivityChanged(ChatActivityChangedAction), #[serde(rename = "session/titleChanged")] diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 0e9f5cba7..cbe2a4a89 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -317,6 +317,8 @@ pub enum ResponsePartKind { SystemNotification, #[serde(rename = "inputRequest")] InputRequest, + #[serde(rename = "error")] + Error, } /// Status of a tool call in the lifecycle state machine. @@ -1616,9 +1618,6 @@ pub struct Turn { pub usage: Option, /// How the turn ended pub state: TurnState, - /// Error details if state is `'error'` - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, } /// An in-progress turn — the assistant is actively streaming. @@ -2285,6 +2284,25 @@ pub struct InputRequestResponsePart { pub response: Option, } +/// An error encountered while processing a turn. +/// +/// This is the detailed source of truth for the error. {@link Turn.state} +/// remains {@link TurnState.Error} while the turn is stopped at this error so +/// clients can detect the terminal state without inspecting response parts. +/// +/// When {@link resumable} is `true`, a client may dispatch `chat/turnResume` +/// while this is the latest turn and its state is {@link TurnState.Error}. +/// Clients decide whether and how to present that affordance. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorResponsePart { + /// Error details. + pub error: ErrorInfo, + /// Whether the host can resume the turn from this error. Only `true` enables resume. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resumable: Option, +} + /// Tool execution result details, available after execution completes. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -4327,6 +4345,8 @@ pub enum ResponsePart { SystemNotification(SystemNotificationResponsePart), #[serde(rename = "inputRequest")] InputRequest(InputRequestResponsePart), + #[serde(rename = "error")] + Error(ErrorResponsePart), /// Unknown or future variant — preserved as raw JSON for round-trip fidelity. /// Reducers treat this as a no-op. #[serde(untagged)] diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 027f48e06..7ea11906e 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -59,15 +59,16 @@ use ahp_types::actions::{ }; use ahp_types::state::{ ActiveTurn, AnnotationsState, ChangesetOperationStatus, ChangesetState, ChangesetStatus, - ChatInputRequest, ChatState, ChildCustomization, ConfirmationOption, Customization, ErrorInfo, - InputRequestResponsePart, McpServerStartingState, McpServerState, McpServerStoppedState, - PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, RootState, - SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, TerminalCommandPart, - TerminalContentPart, TerminalState, TerminalUnclassifiedPart, ToolCallAuthRequiredState, - ToolCallCancellationReason, ToolCallCancelledState, ToolCallCompletedState, - ToolCallConfirmationReason, ToolCallContributor, ToolCallPendingConfirmationState, - ToolCallPendingResultConfirmationState, ToolCallResponsePart, ToolCallRunningState, - ToolCallState, ToolCallStatus, ToolCallStreamingState, ToolInput, Turn, TurnState, + ChatInputRequest, ChatState, ChildCustomization, ConfirmationOption, Customization, + ErrorResponsePart, InputRequestResponsePart, McpServerStartingState, McpServerState, + McpServerStoppedState, PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, + RootState, SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, + TerminalCommandPart, TerminalContentPart, TerminalState, TerminalUnclassifiedPart, + ToolCallAuthRequiredState, ToolCallCancellationReason, ToolCallCancelledState, + ToolCallCompletedState, ToolCallConfirmationReason, ToolCallContributor, + ToolCallPendingConfirmationState, ToolCallPendingResultConfirmationState, ToolCallResponsePart, + ToolCallRunningState, ToolCallState, ToolCallStatus, ToolCallStreamingState, ToolInput, Turn, + TurnState, }; /// What happened when an action was applied. @@ -340,7 +341,7 @@ fn end_turn( duration: i64, turn_state: TurnState, terminal_status: Option, - error: Option, + error_part: Option, ) -> ReduceOutcome { let Some(active) = state.active_turn.as_ref() else { return ReduceOutcome::NoOp; @@ -350,7 +351,7 @@ fn end_turn( } let active = state.active_turn.take().unwrap(); - let response_parts: Vec = active + let mut response_parts: Vec = active .response_parts .into_iter() .map(|part| match part { @@ -397,6 +398,9 @@ fn end_turn( other => other, }) .collect(); + if let Some(error_part) = error_part { + response_parts.push(ResponsePart::Error(error_part)); + } // Defensive clamp: `duration` is producer-supplied and opaque to this // reducer, but a negative value would be nonsensical to display. @@ -408,7 +412,6 @@ fn end_turn( response_parts, usage: active.usage, state: turn_state, - error, }; state.turns.push(turn); @@ -596,7 +599,8 @@ where ResponsePart::ToolCall(tc) => Some(tool_call_id(&tc.tool_call).to_owned()), ResponsePart::Markdown(m) => Some(m.id.clone()), ResponsePart::Reasoning(r) => Some(r.id.clone()), - ResponsePart::ContentRef(_) + ResponsePart::Error(_) + | ResponsePart::ContentRef(_) | ResponsePart::SystemNotification(_) | ResponsePart::InputRequest(_) | ResponsePart::Unknown(_) => None, @@ -967,6 +971,9 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu if active.id != a.turn_id { return ReduceOutcome::NoOp; } + if matches!(a.part, ResponsePart::Error(_)) { + return ReduceOutcome::NoOp; + } active.response_parts.push(a.part.clone()); ReduceOutcome::Applied } @@ -992,8 +999,38 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu a.duration, TurnState::Error, Some(SessionStatus::Error), - Some(a.error.clone()), + Some(a.part.clone()), ), + StateAction::ChatTurnResume(a) => { + if state.active_turn.is_some() { + return ReduceOutcome::NoOp; + } + let Some(turn) = state.turns.last_mut() else { + return ReduceOutcome::NoOp; + }; + if turn.id != a.turn_id || turn.state != TurnState::Error { + return ReduceOutcome::NoOp; + } + let Some(ResponsePart::Error(error)) = turn.response_parts.last() else { + return ReduceOutcome::NoOp; + }; + if error.resumable != Some(true) { + return ReduceOutcome::NoOp; + } + + let turn = state.turns.pop().unwrap(); + 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, + }); + refresh_summary_status(state); + state.status = with_status_flag(state.status, SessionStatus::IsRead, false); + touch_chat_modified(state); + ReduceOutcome::Applied + } StateAction::ChatActivityChanged(a) => { state.activity = a.activity.clone(); ReduceOutcome::Applied diff --git a/clients/swift/AHPApp/AHPApp/Views/ChatView.swift b/clients/swift/AHPApp/AHPApp/Views/ChatView.swift index 060e4b9b1..fc872ff11 100644 --- a/clients/swift/AHPApp/AHPApp/Views/ChatView.swift +++ b/clients/swift/AHPApp/AHPApp/Views/ChatView.swift @@ -772,16 +772,6 @@ struct TurnView: View { ResponsePartView(part: part) } - // Turn status footer - if turn.state == .error, let error = turn.error { - Label(error.message, systemImage: "exclamationmark.triangle.fill") - .font(.footnote) - .foregroundStyle(.red) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.red.opacity(0.1), in: Capsule()) - } - if turn.state == .cancelled { Label("Cancelled", systemImage: "xmark.circle") .font(.footnote) diff --git a/clients/swift/AHPApp/AHPApp/Views/ResponsePartView.swift b/clients/swift/AHPApp/AHPApp/Views/ResponsePartView.swift index 21938d075..b27278448 100644 --- a/clients/swift/AHPApp/AHPApp/Views/ResponsePartView.swift +++ b/clients/swift/AHPApp/AHPApp/Views/ResponsePartView.swift @@ -26,10 +26,29 @@ struct ResponsePartView: View { ContentRefView(ref: ref) case .systemNotification(let note): SystemNotificationPartView(part: note) + case .error(let error): + ErrorResponsePartView(part: error) + case .inputRequest, .unknown: + EmptyView() } } } +// MARK: - ErrorResponsePartView + +struct ErrorResponsePartView: View { + let part: ErrorResponsePart + + var body: some View { + Label(part.error.message, systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(.red) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.red.opacity(0.1), in: Capsule()) + } +} + // MARK: - SystemNotificationPartView struct SystemNotificationPartView: View { diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index da101cfd4..356f0ae24 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -29,6 +29,7 @@ public enum ActionType: String, Codable, Sendable { case chatTurnComplete = "chat/turnComplete" case chatTurnCancelled = "chat/turnCancelled" case chatError = "chat/error" + case chatTurnResume = "chat/turnResume" case chatActivityChanged = "chat/activityChanged" case chatWorkingDirectorySet = "chat/workingDirectorySet" case chatWorkingDirectoryRemoved = "chat/workingDirectoryRemoved" @@ -337,7 +338,7 @@ public struct ChatResponsePartAction: Codable, Sendable { public var type: ActionType /// Turn identifier public var turnId: String - /// Response part (markdown or content ref) + /// Response part to append; error parts are ignored. public var part: ResponsePart /// Additional provider-specific metadata for this action. /// @@ -893,8 +894,9 @@ public struct ChatErrorAction: Codable, Sendable { /// client clocks may differ — and MUST treat it as opaque, producer-supplied /// data. public var duration: Int - /// Error details - public var error: ErrorInfo + /// Error part to append to the response stream before finalizing the turn. + /// Its optional `resumable` flag indicates whether the turn can be resumed. + public var part: ErrorResponsePart /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -908,7 +910,7 @@ public struct ChatErrorAction: Codable, Sendable { case type case turnId case duration - case error + case part case meta = "_meta" } @@ -916,17 +918,31 @@ public struct ChatErrorAction: Codable, Sendable { type: ActionType, turnId: String, duration: Int, - error: ErrorInfo, + part: ErrorResponsePart, meta: [String: AnyCodable]? = nil ) { self.type = type self.turnId = turnId self.duration = duration - self.error = error + self.part = part self.meta = meta } } +public struct ChatTurnResumeAction: Codable, Sendable { + public var type: ActionType + /// Identifier of the errored turn. + public var turnId: String + + public init( + type: ActionType, + turnId: String + ) { + self.type = type + self.turnId = turnId + } +} + public struct ChatActivityChangedAction: Codable, Sendable { public var type: ActionType /// Human-readable description of current activity; omit or set `undefined` to clear @@ -2040,6 +2056,7 @@ public enum StateAction: Codable, Sendable { case chatTurnComplete(ChatTurnCompleteAction) case chatTurnCancelled(ChatTurnCancelledAction) case chatError(ChatErrorAction) + case chatTurnResume(ChatTurnResumeAction) case chatActivityChanged(ChatActivityChangedAction) case sessionTitleChanged(SessionTitleChangedAction) case chatUsage(ChatUsageAction) @@ -2160,6 +2177,8 @@ public enum StateAction: Codable, Sendable { self = .chatTurnCancelled(try ChatTurnCancelledAction(from: decoder)) case "chat/error": self = .chatError(try ChatErrorAction(from: decoder)) + case "chat/turnResume": + self = .chatTurnResume(try ChatTurnResumeAction(from: decoder)) case "chat/activityChanged": self = .chatActivityChanged(try ChatActivityChangedAction(from: decoder)) case "session/titleChanged": @@ -2314,6 +2333,7 @@ public enum StateAction: Codable, Sendable { case .chatTurnComplete(let v): try v.encode(to: encoder) case .chatTurnCancelled(let v): try v.encode(to: encoder) case .chatError(let v): try v.encode(to: encoder) + case .chatTurnResume(let v): try v.encode(to: encoder) case .chatActivityChanged(let v): try v.encode(to: encoder) case .sessionTitleChanged(let v): try v.encode(to: encoder) case .chatUsage(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 7ec7dd9b2..7d7448753 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -208,6 +208,7 @@ public enum ResponsePartKind: String, Codable, Sendable { case reasoning = "reasoning" case systemNotification = "systemNotification" case inputRequest = "inputRequest" + case error = "error" } /// Status of a tool call in the lifecycle state machine. @@ -1545,8 +1546,6 @@ public struct Turn: Codable, Sendable { public var usage: UsageInfo? /// How the turn ended public var state: TurnState - /// Error details if state is `'error'` - public var error: ErrorInfo? public init( id: String, @@ -1555,8 +1554,7 @@ public struct Turn: Codable, Sendable { message: Message, responseParts: [ResponsePart], usage: UsageInfo? = nil, - state: TurnState, - error: ErrorInfo? = nil + state: TurnState ) { self.id = id self.startedAt = startedAt @@ -1565,7 +1563,6 @@ public struct Turn: Codable, Sendable { self.responseParts = responseParts self.usage = usage self.state = state - self.error = error } } @@ -2530,6 +2527,25 @@ public struct InputRequestResponsePart: Codable, Sendable { } } +public struct ErrorResponsePart: Codable, Sendable { + /// Discriminant + public var kind: ResponsePartKind + /// Error details. + public var error: ErrorInfo + /// Whether the host can resume the turn from this error. Only `true` enables resume. + public var resumable: Bool? + + public init( + kind: ResponsePartKind, + error: ErrorInfo, + resumable: Bool? = nil + ) { + self.kind = kind + self.error = error + self.resumable = resumable + } +} + public struct ToolCallResult: Codable, Sendable { /// Whether the tool succeeded public var success: Bool @@ -5338,6 +5354,7 @@ public enum ResponsePart: Codable, Sendable { case reasoning(ReasoningResponsePart) case systemNotification(SystemNotificationResponsePart) case inputRequest(InputRequestResponsePart) + case error(ErrorResponsePart) /// Unknown or future discriminant; the raw payload is preserved /// and re-encoded verbatim for forward-compatibility. case unknown(AnyCodable) @@ -5362,6 +5379,8 @@ public enum ResponsePart: Codable, Sendable { self = .systemNotification(try SystemNotificationResponsePart(from: decoder)) case "inputRequest": self = .inputRequest(try InputRequestResponsePart(from: decoder)) + case "error": + self = .error(try ErrorResponsePart(from: decoder)) default: self = .unknown(try AnyCodable(from: decoder)) } @@ -5375,6 +5394,7 @@ public enum ResponsePart: Codable, Sendable { case .reasoning(let value): try value.encode(to: encoder) case .systemNotification(let value): try value.encode(to: encoder) case .inputRequest(let value): try value.encode(to: encoder) + case .error(let value): try value.encode(to: encoder) case .unknown(let value): try value.encode(to: encoder) } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 4dfa1c2c7..617d4bf66 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -164,6 +164,9 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { guard var activeTurn = state.activeTurn, activeTurn.id == a.turnId else { return state } + if case .error = a.part { + return state + } activeTurn.responseParts.append(a.part) var next = state next.activeTurn = activeTurn @@ -176,7 +179,31 @@ 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, errorPart: a.part) + + case .chatTurnResume(let a): + guard state.activeTurn == nil, + let turn = state.turns.last, + turn.id == a.turnId, + turn.state == .error, + case .error(let errorPart) = turn.responseParts.last, + errorPart.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.status = withStatusFlag(chatSummaryStatus(next), .isRead, false) + next.modifiedAt = currentTimestamp() + return next case .chatActivityChanged(let a): var next = state @@ -884,6 +911,7 @@ public func sessionReducer(state: SessionState, action: StateAction) -> SessionS /// Set of action types that clients are allowed to dispatch. public let clientDispatchableActions: Set = [ "chat/turnStarted", + "chat/turnResume", "chat/toolCallConfirmed", "chat/toolCallComplete", "chat/toolCallResultConfirmed", @@ -905,7 +933,8 @@ public let clientDispatchableActions: Set = [ /// Checks whether an action may be dispatched by a client. public func isClientDispatchable(_ action: StateAction) -> Bool { switch action { - case .chatTurnStarted, .chatToolCallConfirmed, .chatToolCallComplete, + case .chatTurnStarted, .chatTurnResume, + .chatToolCallConfirmed, .chatToolCallComplete, .chatToolCallResultConfirmed, .chatTurnCancelled, .sessionActiveClientSet, .sessionActiveClientRemoved, @@ -1049,13 +1078,13 @@ private func endTurn( duration: Int, turnState: TurnState, terminalStatus: SessionStatus? = nil, - error: ErrorInfo? = nil + errorPart: ErrorResponsePart? = nil ) -> ChatState { guard let activeTurn = state.activeTurn, activeTurn.id == turnId else { return state } - let responseParts: [ResponsePart] = activeTurn.responseParts.map { part in + var responseParts: [ResponsePart] = activeTurn.responseParts.map { part in guard case .toolCall(let tcPart) = part else { return part } let tc = tcPart.toolCall switch tc { @@ -1095,6 +1124,9 @@ private func endTurn( )) } } + if let errorPart { + responseParts.append(.error(errorPart)) + } // Defensive clamp: `duration` is producer-supplied and opaque to this // reducer, but a negative value would be nonsensical to display. @@ -1105,8 +1137,7 @@ private func endTurn( message: activeTurn.message, responseParts: responseParts, usage: activeTurn.usage, - state: turnState, - error: error + state: turnState ) var next = state diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift index 93e7ecb20..e76cee02a 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift @@ -98,6 +98,7 @@ extension ResponsePart { case .contentRef: return nil case .systemNotification: return nil case .inputRequest: return nil + case .error: return nil case .unknown: return nil } } diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift index b234db366..47cf9dde2 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift @@ -88,11 +88,17 @@ final class ReducersTests: XCTestCase { // MARK: - Dispatch Validation func testClientDispatchableReturnsTrue() { - let action: StateAction = .chatTurnStarted(ChatTurnStartedAction( - type: .chatTurnStarted, turnId: T, startedAt: "2026-07-09T20:00:00.000Z", - message: Message(text: "Hello", origin: MessageOrigin(kind: .user)) - )) - XCTAssertTrue(isClientDispatchable(action)) + let actions: [StateAction] = [ + .chatTurnStarted(ChatTurnStartedAction( + type: .chatTurnStarted, turnId: T, startedAt: "2026-07-09T20:00:00.000Z", + message: Message(text: "Hello", origin: MessageOrigin(kind: .user)) + )), + .chatTurnResume(ChatTurnResumeAction( + type: .chatTurnResume, + turnId: T + )), + ] + XCTAssertTrue(actions.allSatisfy(isClientDispatchable)) } func testClientDispatchableReturnsFalse() { diff --git a/docs/.changes/20260811-error-response-parts-and-turn-resume.json b/docs/.changes/20260811-error-response-parts-and-turn-resume.json new file mode 100644 index 000000000..29acc1d80 --- /dev/null +++ b/docs/.changes/20260811-error-response-parts-and-turn-resume.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "Turn errors are durable response parts, and resumable errors can reopen the same turn through `chat/turnResume`." +} diff --git a/docs/guide/actions.md b/docs/guide/actions.md index b863ff52e..1562beffd 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -56,7 +56,8 @@ When a client dispatches an action, the server applies it to the state and also | `chat/usage` | No | Token usage report for the active turn | | `chat/turnComplete` | No | Turn finished (assistant idle) | | `chat/turnCancelled` | **Yes** | Turn was aborted; server stops processing | -| `chat/error` | No | Error during turn processing | +| `chat/error` | No | Error during turn processing; appends an error response part and ends the turn | +| `chat/turnResume` | **Yes** | Resume the latest resumable errored turn without adding another message | | `chat/truncated` | **Yes** | Turn history truncated (with optional `turnId` cutoff) | ### Tool Calls (chat channel) diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index 2f55c06cc..d6d91b144 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -187,10 +187,13 @@ Turn { responseParts: ResponsePart[] // all content in stream order usage: UsageInfo | undefined state: 'complete' | 'cancelled' | 'error' - error?: ErrorInfo } ``` +`state: 'error'` is the convenient top-level signal that processing stopped on +an error. The detailed error and any recovery interaction live in an +`ErrorResponsePart`, preserving their position in the response stream. + ### Active Turn An in-progress turn where the assistant is actively streaming: @@ -344,6 +347,13 @@ InputRequestResponsePart { request: ChatInputRequest // the resolved request, with its final answers response: ChatInputResponseKind // 'accept' | 'decline' | 'cancel' } + +// Durable error record +ErrorResponsePart { + kind: 'error' + error: ErrorInfo + resumable?: boolean +} ``` `SystemNotificationResponsePart._meta` carries provider-specific metadata describing what triggered the notification. A host MAY attach a machine-readable descriptor so clients can categorize, icon, group, filter, or localize the notification without parsing `content`. Clients MAY inspect well-known keys for enhanced UI, and MUST render coherently from `content` alone when `_meta` is absent or unrecognized. @@ -354,6 +364,11 @@ Clients fetch `ContentRef` content separately via the `resourceRead(uri)` comman Consumers can derive display text by concatenating all `markdown` parts, find tool calls by filtering for `toolCall` parts, and access reasoning by filtering for `reasoning` parts. +When the latest errored turn ends in an error part with `resumable: true`, a +client may dispatch `chat/turnResume`. The reducer reopens the same turn without +adding a user message. If processing fails again, the host appends another +error part; prior errors remain in stream order. + ## Tool Call Lifecycle Tool calls are represented as a discriminated union on `status`, where each state only exposes the fields valid for that phase. diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 647646ac3..2484f9440 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -174,9 +174,31 @@ Once a chat exists and its session is `lifecycle: 'ready'`, the chat accepts tur - 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. - The server MAY dispatch `chat/inputRequested` while a turn is active. Clients sync answer drafts with `chat/inputAnswerChanged` and finish the request with `chat/inputCompleted`. +- A `chat/error` appends an error response part before setting the turn state to `error`. When that part has `resumable: true`, a client may dispatch `chat/turnResume` to continue the same turn without another user message. All actions dispatched on this channel travel on `ActionEnvelope`s whose `channel` is the chat URI. Action payloads do NOT carry their own chat URI — the channel comes from the envelope. +### Error recovery + +An error ends the active turn with `TurnState.Error`, providing a simple +top-level signal for clients that do not implement resume. Its +`ErrorResponsePart` is the detailed source of truth: it contains `ErrorInfo` +and may declare the turn resumable with `resumable: true`. Clients decide whether and how to present +that affordance. + +Errors MUST enter the response stream through `chat/error`; reducers ignore an +error part sent through generic `chat/responsePart`. This keeps appending the +detailed error and ending the turn as one atomic state transition. + +Dispatching `chat/turnResume` reopens the same turn. The original message, turn +identifier, response parts, and usage are retained. A successful continuation +eventually finalizes that turn as complete. If continuation fails, `chat/error` +appends another error part. This preserves every failure in response-stream +order without creating a synthetic turn or message. + +The server MUST validate and sequence the resume action before invoking the +provider. A rejected or stale resume MUST NOT produce side effects. + ### Tool call metadata refinement A host MAY open a tool call before all display metadata is known so clients can @@ -225,6 +247,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/turnCancelled` | No active turn | Server MUST reject the action | +| `chat/turnResume` | An active turn exists, `turnId` is not the latest errored turn, or its final error part is not resumable | 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 | | `chat/inputCompleted` | No input request with matching `requestId` | Server SHOULD reject the action | diff --git a/schema/actions.schema.json b/schema/actions.schema.json index fffbb4603..e5afd80af 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -741,7 +741,7 @@ }, "ChatResponsePartAction": { "type": "object", - "description": "Structured content appended to the response.", + "description": "Structured content appended to the response.\n\nAn {@link ErrorResponsePart} MUST be appended with {@link ChatErrorAction}\ninstead so adding the part and ending the turn are one atomic transition.", "properties": { "type": { "const": "chat/responsePart" @@ -752,7 +752,7 @@ }, "part": { "$ref": "#/$defs/ResponsePart", - "description": "Response part (markdown or content ref)" + "description": "Response part to append; error parts are ignored." }, "_meta": { "type": "object", @@ -1258,9 +1258,9 @@ "type": "number", "description": "Elapsed turn duration in milliseconds, measured by the producer's own\nclock. Clients MUST NOT derive this by subtracting timestamps — cross-\nclient clocks may differ — and MUST treat it as opaque, producer-supplied\ndata." }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details" + "part": { + "$ref": "#/$defs/ErrorResponsePart", + "description": "Error part to append to the response stream before finalizing the turn.\nIts optional `resumable` flag indicates whether the turn can be resumed." }, "_meta": { "type": "object", @@ -1272,7 +1272,24 @@ "type", "turnId", "duration", - "error" + "part" + ] + }, + "ChatTurnResumeAction": { + "type": "object", + "description": "Resumes the latest errored turn without adding another message.\n\nThe turn MUST be the latest turn, its state MUST be `error`, and its final\nresponse part MUST be a resumable error. The reducer reopens the same turn\nwith its existing message, response parts, and usage intact. The host then\nresumes the provider's execution for that turn.", + "properties": { + "type": { + "const": "chat/turnResume" + }, + "turnId": { + "type": "string", + "description": "Identifier of the errored turn." + } + }, + "required": [ + "type", + "turnId" ] }, "ChatActivityChangedAction": { @@ -2145,6 +2162,9 @@ { "$ref": "#/$defs/ChatErrorAction" }, + { + "$ref": "#/$defs/ChatTurnResumeAction" + }, { "$ref": "#/$defs/ChatActivityChangedAction" }, @@ -5113,10 +5133,6 @@ "state": { "$ref": "#/$defs/TurnState", "description": "How the turn ended" - }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details if state is `'error'`" } }, "required": [ @@ -5568,6 +5584,28 @@ "request" ] }, + "ErrorResponsePart": { + "type": "object", + "description": "An error encountered while processing a turn.\n\nThis is the detailed source of truth for the error. {@link Turn.state}\nremains {@link TurnState.Error} while the turn is stopped at this error so\nclients can detect the terminal state without inspecting response parts.\n\nWhen {@link resumable} is `true`, a client may dispatch `chat/turnResume`\nwhile this is the latest turn and its state is {@link TurnState.Error}.\nClients decide whether and how to present that affordance.", + "properties": { + "kind": { + "const": "error", + "description": "Discriminant" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Error details." + }, + "resumable": { + "type": "boolean", + "description": "Whether the host can resume the turn from this error. Only `true` enables resume." + } + }, + "required": [ + "kind", + "error" + ] + }, "SystemNotificationResponsePart": { "type": "object", "description": "A system notification surfaced as part of the response stream.\n\nSystem notifications are messages authored by the agent harness\nthat need to be visible to both the agent (for situational awareness) and\nthe user (for transcript continuity). Examples include \"background subagent\nX completed\" or \"task Y was cancelled\".", @@ -7389,6 +7427,9 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + }, + { + "$ref": "#/$defs/ErrorResponsePart" } ] }, @@ -7649,6 +7690,9 @@ { "$ref": "#/$defs/ChatErrorAction" }, + { + "$ref": "#/$defs/ChatTurnResumeAction" + }, { "$ref": "#/$defs/ChatActivityChangedAction" }, diff --git a/schema/commands.schema.json b/schema/commands.schema.json index e65cb0f59..c586c2ff9 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -4422,10 +4422,6 @@ "state": { "$ref": "#/$defs/TurnState", "description": "How the turn ended" - }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details if state is `'error'`" } }, "required": [ @@ -4877,6 +4873,28 @@ "request" ] }, + "ErrorResponsePart": { + "type": "object", + "description": "An error encountered while processing a turn.\n\nThis is the detailed source of truth for the error. {@link Turn.state}\nremains {@link TurnState.Error} while the turn is stopped at this error so\nclients can detect the terminal state without inspecting response parts.\n\nWhen {@link resumable} is `true`, a client may dispatch `chat/turnResume`\nwhile this is the latest turn and its state is {@link TurnState.Error}.\nClients decide whether and how to present that affordance.", + "properties": { + "kind": { + "const": "error", + "description": "Discriminant" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Error details." + }, + "resumable": { + "type": "boolean", + "description": "Whether the host can resume the turn from this error. Only `true` enables resume." + } + }, + "required": [ + "kind", + "error" + ] + }, "SystemNotificationResponsePart": { "type": "object", "description": "A system notification surfaced as part of the response stream.\n\nSystem notifications are messages authored by the agent harness\nthat need to be visible to both the agent (for situational awareness) and\nthe user (for transcript continuity). Examples include \"background subagent\nX completed\" or \"task Y was cancelled\".", @@ -7119,7 +7137,7 @@ }, "ChatResponsePartAction": { "type": "object", - "description": "Structured content appended to the response.", + "description": "Structured content appended to the response.\n\nAn {@link ErrorResponsePart} MUST be appended with {@link ChatErrorAction}\ninstead so adding the part and ending the turn are one atomic transition.", "properties": { "type": { "const": "chat/responsePart" @@ -7130,7 +7148,7 @@ }, "part": { "$ref": "#/$defs/ResponsePart", - "description": "Response part (markdown or content ref)" + "description": "Response part to append; error parts are ignored." }, "_meta": { "type": "object", @@ -7636,9 +7654,9 @@ "type": "number", "description": "Elapsed turn duration in milliseconds, measured by the producer's own\nclock. Clients MUST NOT derive this by subtracting timestamps — cross-\nclient clocks may differ — and MUST treat it as opaque, producer-supplied\ndata." }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details" + "part": { + "$ref": "#/$defs/ErrorResponsePart", + "description": "Error part to append to the response stream before finalizing the turn.\nIts optional `resumable` flag indicates whether the turn can be resumed." }, "_meta": { "type": "object", @@ -7650,7 +7668,24 @@ "type", "turnId", "duration", - "error" + "part" + ] + }, + "ChatTurnResumeAction": { + "type": "object", + "description": "Resumes the latest errored turn without adding another message.\n\nThe turn MUST be the latest turn, its state MUST be `error`, and its final\nresponse part MUST be a resumable error. The reducer reopens the same turn\nwith its existing message, response parts, and usage intact. The host then\nresumes the provider's execution for that turn.", + "properties": { + "type": { + "const": "chat/turnResume" + }, + "turnId": { + "type": "string", + "description": "Identifier of the errored turn." + } + }, + "required": [ + "type", + "turnId" ] }, "ChatActivityChangedAction": { @@ -8609,6 +8644,9 @@ { "$ref": "#/$defs/ChatErrorAction" }, + { + "$ref": "#/$defs/ChatTurnResumeAction" + }, { "$ref": "#/$defs/ChatActivityChangedAction" }, @@ -9237,6 +9275,9 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + }, + { + "$ref": "#/$defs/ErrorResponsePart" } ] }, diff --git a/schema/errors.schema.json b/schema/errors.schema.json index b1b9745fb..e91007997 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -3018,10 +3018,6 @@ "state": { "$ref": "#/$defs/TurnState", "description": "How the turn ended" - }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details if state is `'error'`" } }, "required": [ @@ -3473,6 +3469,28 @@ "request" ] }, + "ErrorResponsePart": { + "type": "object", + "description": "An error encountered while processing a turn.\n\nThis is the detailed source of truth for the error. {@link Turn.state}\nremains {@link TurnState.Error} while the turn is stopped at this error so\nclients can detect the terminal state without inspecting response parts.\n\nWhen {@link resumable} is `true`, a client may dispatch `chat/turnResume`\nwhile this is the latest turn and its state is {@link TurnState.Error}.\nClients decide whether and how to present that affordance.", + "properties": { + "kind": { + "const": "error", + "description": "Discriminant" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Error details." + }, + "resumable": { + "type": "boolean", + "description": "Whether the host can resume the turn from this error. Only `true` enables resume." + } + }, + "required": [ + "kind", + "error" + ] + }, "SystemNotificationResponsePart": { "type": "object", "description": "A system notification surfaced as part of the response stream.\n\nSystem notifications are messages authored by the agent harness\nthat need to be visible to both the agent (for situational awareness) and\nthe user (for transcript continuity). Examples include \"background subagent\nX completed\" or \"task Y was cancelled\".", @@ -6837,6 +6855,9 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + }, + { + "$ref": "#/$defs/ErrorResponsePart" } ] }, @@ -7220,6 +7241,9 @@ { "$ref": "#/$defs/ChatErrorAction" }, + { + "$ref": "#/$defs/ChatTurnResumeAction" + }, { "$ref": "#/$defs/ChatActivityChangedAction" }, @@ -8123,7 +8147,7 @@ }, "ChatResponsePartAction": { "type": "object", - "description": "Structured content appended to the response.", + "description": "Structured content appended to the response.\n\nAn {@link ErrorResponsePart} MUST be appended with {@link ChatErrorAction}\ninstead so adding the part and ending the turn are one atomic transition.", "properties": { "type": { "const": "chat/responsePart" @@ -8134,7 +8158,7 @@ }, "part": { "$ref": "#/$defs/ResponsePart", - "description": "Response part (markdown or content ref)" + "description": "Response part to append; error parts are ignored." }, "_meta": { "type": "object", @@ -8552,9 +8576,9 @@ "type": "number", "description": "Elapsed turn duration in milliseconds, measured by the producer's own\nclock. Clients MUST NOT derive this by subtracting timestamps — cross-\nclient clocks may differ — and MUST treat it as opaque, producer-supplied\ndata." }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details" + "part": { + "$ref": "#/$defs/ErrorResponsePart", + "description": "Error part to append to the response stream before finalizing the turn.\nIts optional `resumable` flag indicates whether the turn can be resumed." }, "_meta": { "type": "object", @@ -8566,7 +8590,24 @@ "type", "turnId", "duration", - "error" + "part" + ] + }, + "ChatTurnResumeAction": { + "type": "object", + "description": "Resumes the latest errored turn without adding another message.\n\nThe turn MUST be the latest turn, its state MUST be `error`, and its final\nresponse part MUST be a resumable error. The reducer reopens the same turn\nwith its existing message, response parts, and usage intact. The host then\nresumes the provider's execution for that turn.", + "properties": { + "type": { + "const": "chat/turnResume" + }, + "turnId": { + "type": "string", + "description": "Identifier of the errored turn." + } + }, + "required": [ + "type", + "turnId" ] }, "ChatActivityChangedAction": { diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index ab95e4b4c..36a62a436 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -3181,10 +3181,6 @@ "state": { "$ref": "#/$defs/TurnState", "description": "How the turn ended" - }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details if state is `'error'`" } }, "required": [ @@ -3636,6 +3632,28 @@ "request" ] }, + "ErrorResponsePart": { + "type": "object", + "description": "An error encountered while processing a turn.\n\nThis is the detailed source of truth for the error. {@link Turn.state}\nremains {@link TurnState.Error} while the turn is stopped at this error so\nclients can detect the terminal state without inspecting response parts.\n\nWhen {@link resumable} is `true`, a client may dispatch `chat/turnResume`\nwhile this is the latest turn and its state is {@link TurnState.Error}.\nClients decide whether and how to present that affordance.", + "properties": { + "kind": { + "const": "error", + "description": "Discriminant" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Error details." + }, + "resumable": { + "type": "boolean", + "description": "Whether the host can resume the turn from this error. Only `true` enables resume." + } + }, + "required": [ + "kind", + "error" + ] + }, "SystemNotificationResponsePart": { "type": "object", "description": "A system notification surfaced as part of the response stream.\n\nSystem notifications are messages authored by the agent harness\nthat need to be visible to both the agent (for situational awareness) and\nthe user (for transcript continuity). Examples include \"background subagent\nX completed\" or \"task Y was cancelled\".", @@ -5515,6 +5533,9 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + }, + { + "$ref": "#/$defs/ErrorResponsePart" } ] }, diff --git a/schema/state.schema.json b/schema/state.schema.json index d594f6b42..3beee3027 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -2929,10 +2929,6 @@ "state": { "$ref": "#/$defs/TurnState", "description": "How the turn ended" - }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details if state is `'error'`" } }, "required": [ @@ -3384,6 +3380,28 @@ "request" ] }, + "ErrorResponsePart": { + "type": "object", + "description": "An error encountered while processing a turn.\n\nThis is the detailed source of truth for the error. {@link Turn.state}\nremains {@link TurnState.Error} while the turn is stopped at this error so\nclients can detect the terminal state without inspecting response parts.\n\nWhen {@link resumable} is `true`, a client may dispatch `chat/turnResume`\nwhile this is the latest turn and its state is {@link TurnState.Error}.\nClients decide whether and how to present that affordance.", + "properties": { + "kind": { + "const": "error", + "description": "Discriminant" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Error details." + }, + "resumable": { + "type": "boolean", + "description": "Whether the host can resume the turn from this error. Only `true` enables resume." + } + }, + "required": [ + "kind", + "error" + ] + }, "SystemNotificationResponsePart": { "type": "object", "description": "A system notification surfaced as part of the response stream.\n\nSystem notifications are messages authored by the agent harness\nthat need to be visible to both the agent (for situational awareness) and\nthe user (for transcript continuity). Examples include \"background subagent\nX completed\" or \"task Y was cancelled\".", @@ -5205,6 +5223,9 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + }, + { + "$ref": "#/$defs/ErrorResponsePart" } ] }, diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 34e8eabd1..cf698be96 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -763,6 +763,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'ReasoningResponsePart' }, { name: 'SystemNotificationResponsePart' }, { name: 'InputRequestResponsePart' }, + { name: 'ErrorResponsePart' }, { name: 'ToolCallResult' }, { name: 'ToolCallRiskAssessmentLoadingState' }, { name: 'ToolCallRiskAssessmentCompleteState' }, @@ -842,6 +843,7 @@ const RESPONSE_PART_UNION: UnionConfig = { { variantName: 'Reasoning', innerType: 'ReasoningResponsePart', wireValue: 'reasoning' }, { variantName: 'SystemNotification', innerType: 'SystemNotificationResponsePart', wireValue: 'systemNotification' }, { variantName: 'InputRequest', innerType: 'InputRequestResponsePart', wireValue: 'inputRequest' }, + { variantName: 'Error', innerType: 'ErrorResponsePart', wireValue: 'error' }, ], unknown: true, }; @@ -1392,6 +1394,7 @@ const ACTION_VARIANTS: { { type: 'chat/turnComplete', variantName: 'ChatTurnComplete', tsInterface: 'ChatTurnCompleteAction' }, { type: 'chat/turnCancelled', variantName: 'ChatTurnCancelled', tsInterface: 'ChatTurnCancelledAction' }, { type: 'chat/error', variantName: 'ChatError', tsInterface: 'ChatErrorAction' }, + { type: 'chat/turnResume', variantName: 'ChatTurnResume', tsInterface: 'ChatTurnResumeAction' }, { type: 'chat/activityChanged', variantName: 'ChatActivityChanged', tsInterface: 'ChatActivityChangedAction' }, { type: 'session/titleChanged', variantName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', variantName: 'ChatUsage', tsInterface: 'ChatUsageAction' }, diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 81a192deb..bdd1e22c3 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -931,6 +931,7 @@ const STATE_STRUCTS = [ 'MarkdownResponsePart', 'ContentRef', 'ResourceResponsePart', 'ToolCallResponsePart', 'ReasoningResponsePart', 'SystemNotificationResponsePart', 'InputRequestResponsePart', + 'ErrorResponsePart', 'ToolCallResult', 'ToolCallStreamingState', 'ToolCallPendingConfirmationState', 'ToolCallRunningState', 'ToolCallAuthRequiredState', 'ToolCallPendingResultConfirmationState', 'ToolCallCompletedState', @@ -970,6 +971,7 @@ const RESPONSE_PART_UNION: UnionConfig = { { caseName: 'Reasoning', structName: 'ReasoningResponsePart', discriminantValue: 'reasoning' }, { caseName: 'SystemNotification', structName: 'SystemNotificationResponsePart', discriminantValue: 'systemNotification' }, { caseName: 'InputRequest', structName: 'InputRequestResponsePart', discriminantValue: 'inputRequest' }, + { caseName: 'Error', structName: 'ErrorResponsePart', discriminantValue: 'error' }, ], unknown: true, }; @@ -1331,6 +1333,7 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'chat/turnComplete', caseName: 'ChatTurnComplete', tsInterface: 'ChatTurnCompleteAction' }, { type: 'chat/turnCancelled', caseName: 'ChatTurnCancelled', tsInterface: 'ChatTurnCancelledAction' }, { type: 'chat/error', caseName: 'ChatError', tsInterface: 'ChatErrorAction' }, + { type: 'chat/turnResume', caseName: 'ChatTurnResume', tsInterface: 'ChatTurnResumeAction' }, { type: 'chat/activityChanged', caseName: 'ChatActivityChanged', tsInterface: 'ChatActivityChangedAction' }, { type: 'session/titleChanged', caseName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', caseName: 'ChatUsage', tsInterface: 'ChatUsageAction' }, diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 58a3bb9e7..3877a05b2 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -511,6 +511,8 @@ function generateRustEnum(enumDecl: EnumDeclaration): string { interface StructOpts { /** Omit fields flagged as literal discriminants (for union variants). */ omitDiscriminants?: boolean; + /** Omit Serialize derive when a hand-written implementation is emitted. */ + omitSerialize?: boolean; /** Force `Default` derive (synthesizes Default impl when all fields optional). */ deriveDefault?: boolean; /** Docstring for the struct itself. */ @@ -527,7 +529,11 @@ function generateRustStruct(rustName: string, props: RustProp[], opts: StructOpt for (const d of opts.doc.split('\n')) lines.push(`/// ${d.trimEnd()}`); } - const derives = ['Debug', 'Clone', 'PartialEq', 'Serialize', 'Deserialize']; + const derives = ['Debug', 'Clone', 'PartialEq']; + if (!opts.omitSerialize) { + derives.push('Serialize'); + } + derives.push('Deserialize'); if (wantsDefault) derives.push('Default'); lines.push(`#[derive(${derives.join(', ')})]`); lines.push('#[serde(rename_all = "camelCase")]'); @@ -746,6 +752,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'ReasoningResponsePart', omitDiscriminants: true }, { name: 'SystemNotificationResponsePart', omitDiscriminants: true }, { name: 'InputRequestResponsePart', omitDiscriminants: true }, + { name: 'ErrorResponsePart', omitDiscriminants: true }, { name: 'ToolCallResult' }, { name: 'ToolCallRiskAssessmentLoadingState', omitDiscriminants: true }, { name: 'ToolCallRiskAssessmentCompleteState', omitDiscriminants: true }, @@ -825,6 +832,7 @@ const RESPONSE_PART_UNION: UnionConfig = { { variantName: 'Reasoning', innerType: 'ReasoningResponsePart', wireValue: 'reasoning' }, { variantName: 'SystemNotification', innerType: 'SystemNotificationResponsePart', wireValue: 'systemNotification' }, { variantName: 'InputRequest', innerType: 'InputRequestResponsePart', wireValue: 'inputRequest' }, + { variantName: 'Error', innerType: 'ErrorResponsePart', wireValue: 'error' }, ], unknown: true, }; @@ -1231,6 +1239,7 @@ const ACTION_VARIANTS: { { type: 'chat/turnComplete', variantName: 'ChatTurnComplete', tsInterface: 'ChatTurnCompleteAction' }, { type: 'chat/turnCancelled', variantName: 'ChatTurnCancelled', tsInterface: 'ChatTurnCancelledAction' }, { type: 'chat/error', variantName: 'ChatError', tsInterface: 'ChatErrorAction' }, + { type: 'chat/turnResume', variantName: 'ChatTurnResume', tsInterface: 'ChatTurnResumeAction' }, { type: 'chat/activityChanged', variantName: 'ChatActivityChanged', tsInterface: 'ChatActivityChangedAction' }, { type: 'session/titleChanged', variantName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', variantName: 'ChatUsage', tsInterface: 'ChatUsageAction' }, @@ -1327,10 +1336,49 @@ pub struct ${scope}ToolCallConfirmedAction { }`; } +function generateChatErrorActionSerializeImpl(): string { + return `#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ChatErrorActionPart<'a> { + kind: &'static str, + error: &'a ErrorInfo, + #[serde(skip_serializing_if = "Option::is_none")] + resumable: Option, +} + +impl Serialize for ChatErrorAction { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + + let mut state = serializer.serialize_struct( + "ChatErrorAction", + if self.meta.is_some() { 4 } else { 3 }, + )?; + state.serialize_field("turnId", &self.turn_id)?; + state.serialize_field("duration", &self.duration)?; + state.serialize_field( + "part", + &ChatErrorActionPart { + kind: "error", + error: &self.part.error, + resumable: self.part.resumable, + }, + )?; + if let Some(meta) = &self.meta { + state.serialize_field("_meta", meta)?; + } + state.end() + } +}`; +} + function generateActionsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, ErrorInfo, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); + lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, ErrorInfo, ErrorResponsePart, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); lines.push(''); // ActionType enum @@ -1379,7 +1427,12 @@ pub struct ActionEnvelope { try { lines.push(generateStructFromInterface(project, v.tsInterface, undefined, { omitDiscriminants: true, + omitSerialize: v.tsInterface === 'ChatErrorAction', })); + if (v.tsInterface === 'ChatErrorAction') { + lines.push(''); + lines.push(generateChatErrorActionSerializeImpl()); + } lines.push(''); } catch (e) { lines.push(`// TODO: could not generate ${v.tsInterface}: ${e}`); diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 6b486893e..fab16083f 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -640,6 +640,7 @@ const STATE_STRUCTS = [ 'MarkdownResponsePart', 'ContentRef', 'ResourceResponsePart', 'ToolCallResponsePart', 'ReasoningResponsePart', 'SystemNotificationResponsePart', 'InputRequestResponsePart', + 'ErrorResponsePart', 'ToolCallResult', 'ToolCallStreamingState', 'ToolCallPendingConfirmationState', 'ToolCallRunningState', 'ToolCallAuthRequiredState', 'ToolCallPendingResultConfirmationState', 'ToolCallCompletedState', @@ -684,6 +685,7 @@ const RESPONSE_PART_UNION: UnionConfig = { { caseName: 'reasoning', structName: 'ReasoningResponsePart', discriminantValue: 'reasoning' }, { caseName: 'systemNotification', structName: 'SystemNotificationResponsePart', discriminantValue: 'systemNotification' }, { caseName: 'inputRequest', structName: 'InputRequestResponsePart', discriminantValue: 'inputRequest' }, + { caseName: 'error', structName: 'ErrorResponsePart', discriminantValue: 'error' }, ], }; @@ -1225,6 +1227,7 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'chat/turnComplete', caseName: 'chatTurnComplete', tsInterface: 'ChatTurnCompleteAction' }, { type: 'chat/turnCancelled', caseName: 'chatTurnCancelled', tsInterface: 'ChatTurnCancelledAction' }, { type: 'chat/error', caseName: 'chatError', tsInterface: 'ChatErrorAction' }, + { type: 'chat/turnResume', caseName: 'chatTurnResume', tsInterface: 'ChatTurnResumeAction' }, { type: 'chat/activityChanged', caseName: 'chatActivityChanged', tsInterface: 'ChatActivityChangedAction' }, { type: 'session/titleChanged', caseName: 'sessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', caseName: 'chatUsage', tsInterface: 'ChatUsageAction' }, diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index 92cffe482..5dd37b203 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -49,6 +49,7 @@ import type { ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, + ChatTurnResumeAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, @@ -197,6 +198,7 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatTurnResumeAction | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction @@ -221,6 +223,7 @@ export type ClientChatAction = | ChatToolCallResultConfirmedAction | ChatToolCallContentChangedAction | ChatTurnCancelledAction + | ChatTurnResumeAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction | ChatPendingMessageSetAction @@ -403,6 +406,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatTurnComplete]: false, [ActionType.ChatTurnCancelled]: true, [ActionType.ChatError]: false, + [ActionType.ChatTurnResume]: true, [ActionType.ChatActivityChanged]: false, [ActionType.ChatWorkingDirectorySet]: true, [ActionType.ChatWorkingDirectoryRemoved]: true, diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index caecaeb11..07bfb9468 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -5,7 +5,7 @@ */ import { ActionType } from '../common/actions.js'; -import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo, URI } from '../common/state.js'; +import type { StringOrMarkdown, FileEdit, UsageInfo, URI } from '../common/state.js'; import type { McpAuthRequirement } from '../channels-session/state.js'; import type { Message, @@ -16,6 +16,7 @@ import type { ChatInputRequest, ChatInputResponseKind, ConfirmationOption, + ErrorResponsePart, ToolCallContributor, ToolCallRiskAssessment, ToolInput, @@ -118,6 +119,9 @@ export interface ChatDeltaAction { /** * Structured content appended to the response. * + * An {@link ErrorResponsePart} MUST be appended with {@link ChatErrorAction} + * instead so adding the part and ending the turn are one atomic transition. + * * @category Chat Actions * @version 1 */ @@ -125,7 +129,7 @@ export interface ChatResponsePartAction { type: ActionType.ChatResponsePart; /** Turn identifier */ turnId: string; - /** Response part (markdown or content ref) */ + /** Response part to append; error parts are ignored. */ part: ResponsePart; /** * Additional provider-specific metadata for this action. @@ -488,8 +492,11 @@ export interface ChatErrorAction { * data. */ duration: number; - /** Error details */ - error: ErrorInfo; + /** + * Error part to append to the response stream before finalizing the turn. + * Its optional `resumable` flag indicates whether the turn can be resumed. + */ + part: ErrorResponsePart; /** * Additional provider-specific metadata for this action. * @@ -502,6 +509,24 @@ export interface ChatErrorAction { _meta?: Record; } +/** + * Resumes the latest errored turn without adding another message. + * + * The turn MUST be the latest turn, its state MUST be `error`, and its final + * response part MUST be a resumable error. The reducer reopens the same turn + * with its existing message, response parts, and usage intact. The host then + * resumes the provider's execution for that turn. + * + * @category Chat Actions + * @version 1 + * @clientDispatchable + */ +export interface ChatTurnResumeAction { + type: ActionType.ChatTurnResume; + /** Identifier of the errored turn. */ + turnId: string; +} + /** * The activity description of this chat changed. * @@ -821,6 +846,7 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatTurnResumeAction | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index b170cefb2..f994c1205 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -12,6 +12,7 @@ import type { ResponsePart, ToolCallResponsePart, InputRequestResponsePart, + ErrorResponsePart, Turn, PendingMessage, ConfirmationOption, @@ -121,6 +122,15 @@ function findOpenInputRequestPart( return part.kind === ResponsePartKind.InputRequest ? { index, part } : undefined; } +function hasResumableError(turn: Turn): boolean { + const part = turn.responseParts[turn.responseParts.length - 1]; + return part?.kind === ResponsePartKind.Error && part.resumable === true; +} + +function isErrorResponsePart(part: ResponsePart): part is ErrorResponsePart { + return part.kind === ResponsePartKind.Error; +} + /** Bitmask covering the mutually-exclusive activity bits (bits 0–4). */ const STATUS_ACTIVITY_MASK = (1 << 5) - 1; @@ -170,7 +180,7 @@ function endTurn( turnState: TurnState, duration: number, terminalStatus?: SessionStatus.Error, - error?: { errorType: string; message: string; stack?: string }, + errorPart?: ErrorResponsePart, ): ChatState { if (!state.activeTurn || state.activeTurn.id !== turnId) { return state; @@ -197,6 +207,9 @@ function endTurn( }, }; }); + if (errorPart) { + responseParts.push(errorPart); + } const turn: Turn = { id: active.id, @@ -208,7 +221,6 @@ function endTurn( responseParts, usage: active.usage, state: turnState, - error, }; const next: ChatState = { @@ -385,6 +397,9 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st if (!state.activeTurn || state.activeTurn.id !== action.turnId) { return state; } + if (isErrorResponsePart(action.part)) { + return state; + } return { ...state, activeTurn: { @@ -400,7 +415,36 @@ 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.part); + + case ActionType.ChatTurnResume: { + if (state.activeTurn) { + return state; + } + const turnIndex = state.turns.length - 1; + const turn = state.turns[turnIndex]; + if (!turn || turn.id !== action.turnId || turn.state !== TurnState.Error || !hasResumableError(turn)) { + 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.ChatActivityChanged: return { ...state, activity: action.activity }; diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index 8e107a23b..de907b8b7 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -565,8 +565,6 @@ export interface Turn { usage: UsageInfo | undefined; /** How the turn ended */ state: TurnState; - /** Error details if state is `'error'` */ - error?: ErrorInfo; } /** @@ -862,6 +860,7 @@ export const enum ResponsePartKind { Reasoning = 'reasoning', SystemNotification = 'systemNotification', InputRequest = 'inputRequest', + Error = 'error', } /** @@ -925,7 +924,8 @@ export type ResponsePart = | ToolCallResponsePart | ReasoningResponsePart | SystemNotificationResponsePart - | InputRequestResponsePart; + | InputRequestResponsePart + | ErrorResponsePart; /** * A live or resolved input request (elicitation) in the turn response stream. @@ -956,6 +956,28 @@ export interface InputRequestResponsePart { response?: ChatInputResponseKind; } +/** + * An error encountered while processing a turn. + * + * This is the detailed source of truth for the error. {@link Turn.state} + * remains {@link TurnState.Error} while the turn is stopped at this error so + * clients can detect the terminal state without inspecting response parts. + * + * When {@link resumable} is `true`, a client may dispatch `chat/turnResume` + * while this is the latest turn and its state is {@link TurnState.Error}. + * Clients decide whether and how to present that affordance. + * + * @category Response Parts + */ +export interface ErrorResponsePart { + /** Discriminant */ + kind: ResponsePartKind.Error; + /** Error details. */ + error: ErrorInfo; + /** Whether the host can resume the turn from this error. Only `true` enables resume. */ + resumable?: boolean; +} + /** * A system notification surfaced as part of the response stream. * diff --git a/types/common/actions.ts b/types/common/actions.ts index d07164129..708b94463 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -61,6 +61,7 @@ import type { ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, + ChatTurnResumeAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, @@ -145,6 +146,7 @@ export const enum ActionType { ChatTurnComplete = 'chat/turnComplete', ChatTurnCancelled = 'chat/turnCancelled', ChatError = 'chat/error', + ChatTurnResume = 'chat/turnResume', ChatActivityChanged = 'chat/activityChanged', ChatWorkingDirectorySet = 'chat/workingDirectorySet', ChatWorkingDirectoryRemoved = 'chat/workingDirectoryRemoved', @@ -289,6 +291,7 @@ export type StateAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatTurnResumeAction | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction diff --git a/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json b/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json index b27693293..9c9cfe846 100644 --- a/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json +++ b/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json @@ -62,8 +62,7 @@ } ], "usage": null, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json b/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json index 618aefb3b..f72613eb1 100644 --- a/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json +++ b/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json @@ -41,8 +41,7 @@ }, "responseParts": [], "usage": null, - "state": "cancelled", - "error": null + "state": "cancelled" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json b/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json index 6a690e3f3..8a5b0d5de 100644 --- a/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json +++ b/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json @@ -25,9 +25,12 @@ "type": "chat/error", "turnId": "turn-1", "duration": 8999, - "error": { - "errorType": "runtime", - "message": "Something broke" + "part": { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + } } } ], @@ -43,13 +46,17 @@ "kind": "user" } }, - "responseParts": [], + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + } + } + ], "usage": null, - "state": "error", - "error": { - "errorType": "runtime", - "message": "Something broke" - } + "state": "error" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json b/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json index b56073aaa..29f1f8623 100644 --- a/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json +++ b/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json @@ -64,8 +64,7 @@ } ], "usage": null, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json b/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json index 95a3cbefb..1f1a06e2c 100644 --- a/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json +++ b/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json @@ -212,8 +212,7 @@ "inputTokens": 200, "outputTokens": 100 }, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json b/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json index 5ab99dc42..0e204aaee 100644 --- a/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json +++ b/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json @@ -75,8 +75,7 @@ } ], "usage": null, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json b/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json index 750c73816..8e868e077 100644 --- a/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json +++ b/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json @@ -87,8 +87,7 @@ } ], "usage": null, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json b/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json index 4d04fe63e..2290bdb0b 100644 --- a/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json +++ b/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json @@ -64,8 +64,7 @@ } ], "usage": null, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/240-turn-duration-is-clamped-to-zero.json b/types/test-cases/reducers/240-turn-duration-is-clamped-to-zero.json index ea33eebed..20cc10555 100644 --- a/types/test-cases/reducers/240-turn-duration-is-clamped-to-zero.json +++ b/types/test-cases/reducers/240-turn-duration-is-clamped-to-zero.json @@ -41,8 +41,7 @@ }, "responseParts": [], "usage": null, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/250-turncomplete-force-cancels-auth-required-tool-call.json b/types/test-cases/reducers/250-turncomplete-force-cancels-auth-required-tool-call.json index ae0ddefaa..c0bdaa3ea 100644 --- a/types/test-cases/reducers/250-turncomplete-force-cancels-auth-required-tool-call.json +++ b/types/test-cases/reducers/250-turncomplete-force-cancels-auth-required-tool-call.json @@ -94,8 +94,7 @@ } ], "usage": null, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/263-chat-turn-resume-reopens-turn.json b/types/test-cases/reducers/263-chat-turn-resume-reopens-turn.json new file mode 100644 index 000000000..46390ac73 --- /dev/null +++ b/types/test-cases/reducers/263-chat-turn-resume-reopens-turn.json @@ -0,0 +1,79 @@ +{ + "description": "chat/turnResume reopens the latest turn after a resumable error", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 1000, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "markdown", + "id": "markdown-1", + "content": "Partial response" + }, + { + "kind": "error", + "error": { + "errorType": "quota", + "message": "More tokens are required" + }, + "resumable": true + } + ], + "usage": null, + "state": "error" + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnResume", + "turnId": "turn-1" + } + ], + "expected": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "markdown", + "id": "markdown-1", + "content": "Partial response" + }, + { + "kind": "error", + "error": { + "errorType": "quota", + "message": "More tokens are required" + }, + "resumable": true + } + ], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:09.999Z" + } +} diff --git a/types/test-cases/reducers/264-chat-turn-resume-noop-with-active-turn.json b/types/test-cases/reducers/264-chat-turn-resume-noop-with-active-turn.json new file mode 100644 index 000000000..04501fd21 --- /dev/null +++ b/types/test-cases/reducers/264-chat-turn-resume-noop-with-active-turn.json @@ -0,0 +1,48 @@ +{ + "description": "chat/turnResume does nothing while a turn is active", + "reducer": "chat", + "initial": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnResume", + "turnId": "turn-1" + } + ], + "expected": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} diff --git a/types/test-cases/reducers/265-chat-turn-resume-noop-for-unknown-turn.json b/types/test-cases/reducers/265-chat-turn-resume-noop-for-unknown-turn.json new file mode 100644 index 000000000..380eef696 --- /dev/null +++ b/types/test-cases/reducers/265-chat-turn-resume-noop-for-unknown-turn.json @@ -0,0 +1,24 @@ +{ + "description": "chat/turnResume does nothing when the turn is unknown", + "reducer": "chat", + "initial": { + "turns": [], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnResume", + "turnId": "turn-1" + } + ], + "expected": { + "turns": [], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} diff --git a/types/test-cases/reducers/266-chat-turn-resume-noop-for-nonlatest-turn.json b/types/test-cases/reducers/266-chat-turn-resume-noop-for-nonlatest-turn.json new file mode 100644 index 000000000..888dbb598 --- /dev/null +++ b/types/test-cases/reducers/266-chat-turn-resume-noop-for-nonlatest-turn.json @@ -0,0 +1,92 @@ +{ + "description": "chat/turnResume does nothing when the errored turn is not latest", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "resumable": true + } + ], + "usage": null, + "state": "error" + }, + { + "id": "turn-2", + "message": { + "text": "Second", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnResume", + "turnId": "turn-1" + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "resumable": true + } + ], + "usage": null, + "state": "error" + }, + { + "id": "turn-2", + "message": { + "text": "Second", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} diff --git a/types/test-cases/reducers/267-chat-turn-resume-noop-for-unresumable-error.json b/types/test-cases/reducers/267-chat-turn-resume-noop-for-unresumable-error.json new file mode 100644 index 000000000..e8c51a291 --- /dev/null +++ b/types/test-cases/reducers/267-chat-turn-resume-noop-for-unresumable-error.json @@ -0,0 +1,66 @@ +{ + "description": "chat/turnResume does nothing when the latest error is not resumable", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "fatal", + "message": "Cannot resume" + } + } + ], + "usage": null, + "state": "error" + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnResume", + "turnId": "turn-1" + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "fatal", + "message": "Cannot resume" + } + } + ], + "usage": null, + "state": "error" + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} diff --git a/types/test-cases/reducers/268-chat-turn-resume-preserves-errors-across-retries.json b/types/test-cases/reducers/268-chat-turn-resume-preserves-errors-across-retries.json new file mode 100644 index 000000000..49b6addda --- /dev/null +++ b/types/test-cases/reducers/268-chat-turn-resume-preserves-errors-across-retries.json @@ -0,0 +1,93 @@ +{ + "description": "a resumed turn preserves its prior error when it errors again", + "reducer": "chat", + "initial": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/error", + "turnId": "turn-1", + "duration": 1000, + "part": { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "First failure" + }, + "resumable": true + } + }, + { + "type": "chat/turnResume", + "turnId": "turn-1" + }, + { + "type": "chat/error", + "turnId": "turn-1", + "duration": 2000, + "part": { + "kind": "error", + "error": { + "errorType": "fatal", + "message": "Second failure" + } + } + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 2000, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "First failure" + }, + "resumable": true + }, + { + "kind": "error", + "error": { + "errorType": "fatal", + "message": "Second failure" + } + } + ], + "usage": null, + "state": "error" + } + ], + "activeTurn": null, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:09.999Z" + } +} diff --git a/types/test-cases/reducers/269-chat-turn-resume-completes-one-turn.json b/types/test-cases/reducers/269-chat-turn-resume-completes-one-turn.json new file mode 100644 index 000000000..13265b7af --- /dev/null +++ b/types/test-cases/reducers/269-chat-turn-resume-completes-one-turn.json @@ -0,0 +1,82 @@ +{ + "description": "a resumed turn completes as one turn and preserves its error", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 1000, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "resumable": true + } + ], + "usage": null, + "state": "error" + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnResume", + "turnId": "turn-1" + }, + { + "type": "chat/turnComplete", + "turnId": "turn-1", + "duration": 2000 + }, + { + "type": "chat/turnResume", + "turnId": "turn-1" + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 2000, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "resumable": true + } + ], + "usage": null, + "state": "complete" + } + ], + "activeTurn": null, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:09.999Z" + } +} diff --git a/types/test-cases/reducers/270-chat-responsepart-cannot-append-error.json b/types/test-cases/reducers/270-chat-responsepart-cannot-append-error.json new file mode 100644 index 000000000..10d176f88 --- /dev/null +++ b/types/test-cases/reducers/270-chat-responsepart-cannot-append-error.json @@ -0,0 +1,55 @@ +{ + "description": "chat/responsePart cannot append an error without the atomic chat/error transition", + "reducer": "chat", + "initial": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/responsePart", + "turnId": "turn-1", + "part": { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + } + } + } + ], + "expected": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} diff --git a/types/test-cases/round-trips/041-chat-error-part-discriminator.json b/types/test-cases/round-trips/041-chat-error-part-discriminator.json new file mode 100644 index 000000000..51b2c11fe --- /dev/null +++ b/types/test-cases/round-trips/041-chat-error-part-discriminator.json @@ -0,0 +1,34 @@ +{ + "name": "chat-error-part-discriminator", + "group": "A", + "description": "A chat/error action preserves the nested ErrorResponsePart kind discriminator required by the wire schema.", + "type": "StateAction", + "input": { + "type": "chat/error", + "turnId": "turn-1", + "duration": 1000, + "part": { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "resumable": true + } + }, + "acceptableOutputs": [ + { + "type": "chat/error", + "turnId": "turn-1", + "duration": 1000, + "part": { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "resumable": true + } + } + ] +} diff --git a/types/version/registry.ts b/types/version/registry.ts index 205e00e88..5d5727a20 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -123,6 +123,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatTurnComplete]: '0.4.0', [ActionType.ChatTurnCancelled]: '0.4.0', [ActionType.ChatError]: '0.4.0', + [ActionType.ChatTurnResume]: '0.8.0', [ActionType.ChatActivityChanged]: '0.5.0', [ActionType.ChatWorkingDirectorySet]: '0.7.0', [ActionType.ChatWorkingDirectoryRemoved]: '0.7.0',