From 752f7efcc44faa14bf9d75fca01fe794ff3c1dba Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 7 Aug 2026 16:57:49 -0700 Subject: [PATCH 1/5] chat: model error recovery in response parts Represent turn errors as durable response parts with optional host-provided recovery actions. Selecting an action records the decision and reopens the same turn without adding another user message.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahp/reducers.go | 68 +++++++- clients/go/ahptypes/actions.generated.go | 33 +++- clients/go/ahptypes/state.generated.go | 61 ++++++- .../microsoft/agenthostprotocol/Reducers.kt | 61 ++++++- .../generated/Actions.generated.kt | 29 +++- .../generated/State.generated.kt | 66 +++++++- clients/rust/crates/ahp-types/src/actions.rs | 41 ++++- clients/rust/crates/ahp-types/src/state.rs | 66 +++++++- clients/rust/crates/ahp/src/reducers.rs | 74 +++++++-- .../Generated/Actions.generated.swift | 40 ++++- .../Generated/State.generated.swift | 72 +++++++- .../Sources/AgentHostProtocol/Reducers.swift | 55 ++++++- .../ToolCallStateExtensions.swift | 1 + .../ReducersTests.swift | 18 +- .../20260807-error-recovery-actions.json | 4 + docs/guide/actions.md | 3 +- docs/guide/state-model.md | 23 ++- docs/specification/chat-channel.md | 31 ++++ schema/actions.schema.json | 125 ++++++++++++-- schema/commands.schema.json | 122 ++++++++++++-- schema/errors.schema.json | 122 ++++++++++++-- schema/notifications.schema.json | 80 ++++++++- schema/state.schema.json | 80 ++++++++- scripts/generate-go.ts | 5 + scripts/generate-kotlin.ts | 3 + scripts/generate-rust.ts | 7 +- scripts/generate-swift.ts | 3 + types/action-origin.generated.ts | 4 + types/channels-chat/actions.ts | 37 ++++- types/channels-chat/reducer.ts | 73 ++++++++- types/channels-chat/state.ts | 69 +++++++- types/common/actions.ts | 3 + ...4-session-turncomplete-finalizes-turn.json | 3 +- ...-session-turncancelled-finalizes-turn.json | 3 +- ...ssion-error-finalizes-turn-with-error.json | 27 ++- ...-force-cancels-in-progress-tool-calls.json | 3 +- ...w-with-tool-calls-and-re-confirmation.json | 3 +- ...dturn-force-cancels-running-tool-call.json | 3 +- ...nput-turn-end-cleans-turn-scoped-only.json | 3 +- .../161-chat-turn-lifecycle-on-chat.json | 3 +- .../240-turn-duration-is-clamped-to-zero.json | 3 +- ...force-cancels-auth-required-tool-call.json | 3 +- .../263-chat-error-recovery-reopens-turn.json | 110 +++++++++++++ ...-error-recovery-noop-with-active-turn.json | 50 ++++++ ...-error-recovery-noop-for-unknown-turn.json | 26 +++ ...rror-recovery-noop-for-nonlatest-turn.json | 110 +++++++++++++ ...ecovery-noop-without-available-option.json | 154 ++++++++++++++++++ ...overy-preserves-errors-across-retries.json | 114 +++++++++++++ ...hat-error-recovery-completes-one-turn.json | 97 +++++++++++ ...chat-responsepart-cannot-append-error.json | 56 +++++++ types/version/registry.ts | 1 + 51 files changed, 2104 insertions(+), 147 deletions(-) create mode 100644 docs/.changes/20260807-error-recovery-actions.json create mode 100644 types/test-cases/reducers/263-chat-error-recovery-reopens-turn.json create mode 100644 types/test-cases/reducers/264-chat-error-recovery-noop-with-active-turn.json create mode 100644 types/test-cases/reducers/265-chat-error-recovery-noop-for-unknown-turn.json create mode 100644 types/test-cases/reducers/266-chat-error-recovery-noop-for-nonlatest-turn.json create mode 100644 types/test-cases/reducers/267-chat-error-recovery-noop-without-available-option.json create mode 100644 types/test-cases/reducers/268-chat-error-recovery-preserves-errors-across-retries.json create mode 100644 types/test-cases/reducers/269-chat-error-recovery-completes-one-turn.json create mode 100644 types/test-cases/reducers/270-chat-responsepart-cannot-append-error.json diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 9d2a34161..6c14e70cc 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -190,6 +190,16 @@ func hasOpenInputRequest(state *ahptypes.ChatState) bool { return false } +func findAvailableErrorRecoveryPart(responseParts []ahptypes.ResponsePart, partID string) (int, *ahptypes.ErrorResponsePart) { + for i := range responseParts { + part, ok := responseParts[i].Value.(*ahptypes.ErrorResponsePart) + if ok && part.Id == partID && part.Recovery != nil && part.Recovery.SelectedOptionId == nil { + return i, part + } + } + return -1, nil +} + func summaryStatus(state *ahptypes.ChatState, terminal *ahptypes.SessionStatus) ahptypes.SessionStatus { var activity ahptypes.SessionStatus switch { @@ -215,7 +225,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 +263,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 +281,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) @@ -515,6 +527,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 +537,54 @@ 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.ChatErrorRecoverySelectedAction: + 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 { + return ReduceOutcomeNoOp + } + partIndex, recoveryPart := findAvailableErrorRecoveryPart(turn.ResponseParts, a.PartId) + if recoveryPart == nil { + return ReduceOutcomeNoOp + } + optionAvailable := false + for i := range recoveryPart.Recovery.Options { + if recoveryPart.Recovery.Options[i].Id == a.OptionId { + optionAvailable = true + break + } + } + if !optionAvailable { + return ReduceOutcomeNoOp + } + recovery := *recoveryPart.Recovery + selectedOptionID := a.OptionId + recovery.SelectedOptionId = &selectedOptionID + updatedPart := *recoveryPart + updatedPart.Recovery = &recovery + responseParts := append([]ahptypes.ResponsePart(nil), turn.ResponseParts...) + responseParts[partIndex] = ahptypes.ResponsePart{Value: &updatedPart} + + 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: 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..0aa022e8b 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" + ActionTypeChatErrorRecoverySelected ActionType = "chat/errorRecoverySelected" 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 recovery options describe the actions the host can perform. + 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,21 @@ type ChatErrorAction struct { Meta map[string]json.RawMessage `json:"_meta,omitempty"` } +// A client selected one of the host-provided recovery options on an error. +// +// The reducer records the selected option identifier on the existing error +// response part and reopens the same turn without adding another message. The +// host performs the opaque recovery behavior identified by `optionId`. +type ChatErrorRecoverySelectedAction struct { + Type ActionType `json:"type"` + // Identifier of the errored turn. + TurnId string `json:"turnId"` + // Identifier of the error response part. + PartId string `json:"partId"` + // Identifier of the selected recovery option. + OptionId string `json:"optionId"` +} + // The activity description of this chat changed. // // Dispatched by the server to indicate what the chat is currently doing @@ -1515,6 +1535,7 @@ func (*ChatToolCallAuthResolvedAction) isStateAction() {} func (*ChatTurnCompleteAction) isStateAction() {} func (*ChatTurnCancelledAction) isStateAction() {} func (*ChatErrorAction) isStateAction() {} +func (*ChatErrorRecoverySelectedAction) isStateAction() {} func (*ChatActivityChangedAction) isStateAction() {} func (*SessionTitleChangedAction) isStateAction() {} func (*ChatUsageAction) isStateAction() {} @@ -1735,6 +1756,12 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "chat/errorRecoverySelected": + var value ChatErrorRecoverySelectedAction + 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..3f19d8ad6 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,57 @@ type InputRequestResponsePart struct { Response *ChatInputResponseKind `json:"response,omitempty"` } +// An action the host offers to recover from a turn error. +// +// The `id` is opaque to clients. Selecting an option with +// `chat/errorRecoverySelected` asks the host to perform the corresponding +// recovery, such as retrying the request or starting a quota-purchase flow. +type ErrorRecoveryOption struct { + // Stable option identifier, returned in `chat/errorRecoverySelected`. + Id string `json:"id"` + // Human-readable label displayed to the user. + Label string `json:"label"` + // Optional secondary text. + Description *string `json:"description,omitempty"` + // Whether this option is the recommended/default choice. + Recommended *bool `json:"recommended,omitempty"` +} + +// Recovery offered for an error. +// +// Presence of this object means the host offered recovery. `options` MUST +// contain at least one entry with a unique `id`. Recovery is available while +// `selectedOptionId` is absent. Once a client selects an option, the reducer +// records its identifier and reopens the same turn. The error part remains in +// the response stream so the failure and recovery decision stay visible in +// history. +type ErrorRecovery struct { + // Ordered recovery options supplied by the host. + Options []ErrorRecoveryOption `json:"options"` + // Identifier of the option selected by the user, absent until recovery is requested. + SelectedOptionId *string `json:"selectedOptionId,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 `recovery` is absent, the error is not recoverable. When it is present +// and `selectedOptionId` is absent, a client may select one of its host-provided +// options with `chat/errorRecoverySelected`. +type ErrorResponsePart struct { + // Discriminant + Kind ResponsePartKind `json:"kind"` + // Stable part identifier. + Id string `json:"id"` + // Error details. + Error ErrorInfo `json:"error"` + // Recovery offered by the host, if any. + Recovery *ErrorRecovery `json:"recovery,omitempty"` +} + // Tool execution result details, available after execution completes. type ToolCallResult struct { // Whether the tool succeeded @@ -3605,6 +3655,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 +3707,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..b30bc8566 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,54 @@ 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 StateActionChatErrorRecoverySelected -> { + 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 recoveryPartIndex = turn.responseParts.indexOfFirst { part -> + part is ResponsePartError && + part.value.id == a.partId && + part.value.recovery != null && + part.value.recovery.selectedOptionId == null + } + val recoveryPart = turn.responseParts.getOrNull(recoveryPartIndex) as? ResponsePartError + val recovery = recoveryPart?.value?.recovery + val hasSelectedOption = recovery?.options?.any { it.id == a.optionId } == true + if (recoveryPart == null || recovery == null || !hasSelectedOption) { + state + } else { + val responseParts = turn.responseParts.toMutableList() + responseParts[recoveryPartIndex] = ResponsePartError( + recoveryPart.value.copy( + recovery = recovery.copy(selectedOptionId = a.optionId), + ), + ) + val withTurn = state.copy( + turns = state.turns.dropLast(1), + activeTurn = ActiveTurn( + id = turn.id, + startedAt = turn.startedAt ?: state.modifiedAt, + message = turn.message, + responseParts = 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..0cd16acf4 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/errorRecoverySelected") + CHAT_ERROR_RECOVERY_SELECTED, @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 recovery options describe the actions the host can perform. */ - val error: ErrorInfo, + val part: ErrorResponsePart, /** * Additional provider-specific metadata for this action. * @@ -767,6 +770,23 @@ data class ChatErrorAction( val meta: Map? = null ) +@Serializable +data class ChatErrorRecoverySelectedAction( + val type: ActionType, + /** + * Identifier of the errored turn. + */ + val turnId: String, + /** + * Identifier of the error response part. + */ + val partId: String, + /** + * Identifier of the selected recovery option. + */ + val optionId: String +) + @Serializable data class ChatActivityChangedAction( val type: ActionType, @@ -1560,6 +1580,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 StateActionChatErrorRecoverySelected(val value: ChatErrorRecoverySelectedAction) : 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 +1681,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/errorRecoverySelected" -> StateActionChatErrorRecoverySelected(input.json.decodeFromJsonElement(ChatErrorRecoverySelectedAction.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 +1775,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 StateActionChatErrorRecoverySelected -> output.json.encodeToJsonElement(ChatErrorRecoverySelectedAction.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..25a26668c 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,58 @@ data class InputRequestResponsePart( val response: ChatInputResponseKind? = null ) +@Serializable +data class ErrorRecoveryOption( + /** + * Stable option identifier, returned in `chat/errorRecoverySelected`. + */ + val id: String, + /** + * Human-readable label displayed to the user. + */ + val label: String, + /** + * Optional secondary text. + */ + val description: String? = null, + /** + * Whether this option is the recommended/default choice. + */ + val recommended: Boolean? = null +) + +@Serializable +data class ErrorRecovery( + /** + * Ordered recovery options supplied by the host. + */ + val options: List, + /** + * Identifier of the option selected by the user, absent until recovery is requested. + */ + val selectedOptionId: String? = null +) + +@Serializable +data class ErrorResponsePart( + /** + * Discriminant + */ + val kind: ResponsePartKind, + /** + * Stable part identifier. + */ + val id: String, + /** + * Error details. + */ + val error: ErrorInfo, + /** + * Recovery offered by the host, if any. + */ + val recovery: ErrorRecovery? = null +) + @Serializable data class ToolCallResult( /** @@ -4785,6 +4835,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 +4867,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 +4882,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..5e37da1c5 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/errorRecoverySelected")] + ChatErrorRecoverySelected, #[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. /// @@ -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 recovery options describe the actions the host can perform. + 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,22 @@ pub struct ChatErrorAction { pub meta: Option, } +/// A client selected one of the host-provided recovery options on an error. +/// +/// The reducer records the selected option identifier on the existing error +/// response part and reopens the same turn without adding another message. The +/// host performs the opaque recovery behavior identified by `optionId`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatErrorRecoverySelectedAction { + /// Identifier of the errored turn. + pub turn_id: String, + /// Identifier of the error response part. + pub part_id: String, + /// Identifier of the selected recovery option. + pub option_id: String, +} + /// The activity description of this chat changed. /// /// Dispatched by the server to indicate what the chat is currently doing @@ -1827,6 +1850,8 @@ pub enum StateAction { ChatTurnCancelled(ChatTurnCancelledAction), #[serde(rename = "chat/error")] ChatError(ChatErrorAction), + #[serde(rename = "chat/errorRecoverySelected")] + ChatErrorRecoverySelected(ChatErrorRecoverySelectedAction), #[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..13cbf43c4 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,65 @@ pub struct InputRequestResponsePart { pub response: Option, } +/// An action the host offers to recover from a turn error. +/// +/// The `id` is opaque to clients. Selecting an option with +/// `chat/errorRecoverySelected` asks the host to perform the corresponding +/// recovery, such as retrying the request or starting a quota-purchase flow. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorRecoveryOption { + /// Stable option identifier, returned in `chat/errorRecoverySelected`. + pub id: String, + /// Human-readable label displayed to the user. + pub label: String, + /// Optional secondary text. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Whether this option is the recommended/default choice. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recommended: Option, +} + +/// Recovery offered for an error. +/// +/// Presence of this object means the host offered recovery. `options` MUST +/// contain at least one entry with a unique `id`. Recovery is available while +/// `selectedOptionId` is absent. Once a client selects an option, the reducer +/// records its identifier and reopens the same turn. The error part remains in +/// the response stream so the failure and recovery decision stay visible in +/// history. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorRecovery { + /// Ordered recovery options supplied by the host. + pub options: Vec, + /// Identifier of the option selected by the user, absent until recovery is requested. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_option_id: 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 `recovery` is absent, the error is not recoverable. When it is present +/// and `selectedOptionId` is absent, a client may select one of its host-provided +/// options with `chat/errorRecoverySelected`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorResponsePart { + /// Stable part identifier. + pub id: String, + /// Error details. + pub error: ErrorInfo, + /// Recovery offered by the host, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recovery: Option, +} + /// Tool execution result details, available after execution completes. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -4327,6 +4385,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..5e145bcaa 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,6 +599,7 @@ 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::Error(error) => Some(error.id.clone()), ResponsePart::ContentRef(_) | ResponsePart::SystemNotification(_) | ResponsePart::InputRequest(_) @@ -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,49 @@ 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::ChatErrorRecoverySelected(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(recovery) = turn.response_parts.iter_mut().find_map(|part| match part { + ResponsePart::Error(error) if error.id == a.part_id => error + .recovery + .as_mut() + .filter(|recovery| recovery.selected_option_id.is_none()), + _ => None, + }) else { + return ReduceOutcome::NoOp; + }; + if !recovery + .options + .iter() + .any(|option| option.id == a.option_id) + { + return ReduceOutcome::NoOp; + } + recovery.selected_option_id = Some(a.option_id.clone()); + + 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/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index da101cfd4..5546d8ed4 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 chatErrorRecoverySelected = "chat/errorRecoverySelected" 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 recovery options describe the actions the host can perform. + 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,39 @@ 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 ChatErrorRecoverySelectedAction: Codable, Sendable { + public var type: ActionType + /// Identifier of the errored turn. + public var turnId: String + /// Identifier of the error response part. + public var partId: String + /// Identifier of the selected recovery option. + public var optionId: String + + public init( + type: ActionType, + turnId: String, + partId: String, + optionId: String + ) { + self.type = type + self.turnId = turnId + self.partId = partId + self.optionId = optionId + } +} + public struct ChatActivityChangedAction: Codable, Sendable { public var type: ActionType /// Human-readable description of current activity; omit or set `undefined` to clear @@ -2040,6 +2064,7 @@ public enum StateAction: Codable, Sendable { case chatTurnComplete(ChatTurnCompleteAction) case chatTurnCancelled(ChatTurnCancelledAction) case chatError(ChatErrorAction) + case chatErrorRecoverySelected(ChatErrorRecoverySelectedAction) case chatActivityChanged(ChatActivityChangedAction) case sessionTitleChanged(SessionTitleChangedAction) case chatUsage(ChatUsageAction) @@ -2160,6 +2185,8 @@ public enum StateAction: Codable, Sendable { self = .chatTurnCancelled(try ChatTurnCancelledAction(from: decoder)) case "chat/error": self = .chatError(try ChatErrorAction(from: decoder)) + case "chat/errorRecoverySelected": + self = .chatErrorRecoverySelected(try ChatErrorRecoverySelectedAction(from: decoder)) case "chat/activityChanged": self = .chatActivityChanged(try ChatActivityChangedAction(from: decoder)) case "session/titleChanged": @@ -2314,6 +2341,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 .chatErrorRecoverySelected(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..7f240615f 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,67 @@ public struct InputRequestResponsePart: Codable, Sendable { } } +public struct ErrorRecoveryOption: Codable, Sendable { + /// Stable option identifier, returned in `chat/errorRecoverySelected`. + public var id: String + /// Human-readable label displayed to the user. + public var label: String + /// Optional secondary text. + public var description: String? + /// Whether this option is the recommended/default choice. + public var recommended: Bool? + + public init( + id: String, + label: String, + description: String? = nil, + recommended: Bool? = nil + ) { + self.id = id + self.label = label + self.description = description + self.recommended = recommended + } +} + +public struct ErrorRecovery: Codable, Sendable { + /// Ordered recovery options supplied by the host. + public var options: [ErrorRecoveryOption] + /// Identifier of the option selected by the user, absent until recovery is requested. + public var selectedOptionId: String? + + public init( + options: [ErrorRecoveryOption], + selectedOptionId: String? = nil + ) { + self.options = options + self.selectedOptionId = selectedOptionId + } +} + +public struct ErrorResponsePart: Codable, Sendable { + /// Discriminant + public var kind: ResponsePartKind + /// Stable part identifier. + public var id: String + /// Error details. + public var error: ErrorInfo + /// Recovery offered by the host, if any. + public var recovery: ErrorRecovery? + + public init( + kind: ResponsePartKind, + id: String, + error: ErrorInfo, + recovery: ErrorRecovery? = nil + ) { + self.kind = kind + self.id = id + self.error = error + self.recovery = recovery + } +} + public struct ToolCallResult: Codable, Sendable { /// Whether the tool succeeded public var success: Bool @@ -5338,6 +5396,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 +5421,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 +5436,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..7632e15ce 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,43 @@ 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 .chatErrorRecoverySelected(let a): + guard state.activeTurn == nil, + let turn = state.turns.last, + turn.id == a.turnId, + turn.state == .error, + let recoveryPartIndex = turn.responseParts.firstIndex(where: { part in + guard case .error(let errorPart) = part else { return false } + return errorPart.id == a.partId + && errorPart.recovery != nil + && errorPart.recovery?.selectedOptionId == nil + }), + case .error(var errorPart) = turn.responseParts[recoveryPartIndex], + var recovery = errorPart.recovery, + recovery.options.contains(where: { $0.id == a.optionId }) + else { + return state + } + + recovery.selectedOptionId = a.optionId + errorPart.recovery = recovery + var responseParts = turn.responseParts + responseParts[recoveryPartIndex] = .error(errorPart) + + var next = state + next.turns.removeLast() + next.activeTurn = ActiveTurn( + id: turn.id, + startedAt: turn.startedAt ?? state.modifiedAt, + message: turn.message, + responseParts: 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 +923,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/errorRecoverySelected", "chat/toolCallConfirmed", "chat/toolCallComplete", "chat/toolCallResultConfirmed", @@ -905,7 +945,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, .chatErrorRecoverySelected, + .chatToolCallConfirmed, .chatToolCallComplete, .chatToolCallResultConfirmed, .chatTurnCancelled, .sessionActiveClientSet, .sessionActiveClientRemoved, @@ -1049,13 +1090,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 +1136,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 +1149,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..23b5d4a1e 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(let error): return error.id case .unknown: return nil } } diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift index b234db366..4145e5b55 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift @@ -88,11 +88,19 @@ 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)) + )), + .chatErrorRecoverySelected(ChatErrorRecoverySelectedAction( + type: .chatErrorRecoverySelected, + turnId: T, + partId: "error-1", + optionId: "retry" + )), + ] + XCTAssertTrue(actions.allSatisfy(isClientDispatchable)) } func testClientDispatchableReturnsFalse() { diff --git a/docs/.changes/20260807-error-recovery-actions.json b/docs/.changes/20260807-error-recovery-actions.json new file mode 100644 index 000000000..f539d87d3 --- /dev/null +++ b/docs/.changes/20260807-error-recovery-actions.json @@ -0,0 +1,4 @@ +{ + "type": "changed", + "message": "Turn errors are durable response parts with optional host-provided recovery actions selected through `chat/errorRecoverySelected`." +} diff --git a/docs/guide/actions.md b/docs/guide/actions.md index b863ff52e..81306a924 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/errorRecoverySelected` | **Yes** | User selected a host-provided recovery option; records the choice and continues the same turn | | `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..d433ef835 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,17 @@ InputRequestResponsePart { request: ChatInputRequest // the resolved request, with its final answers response: ChatInputResponseKind // 'accept' | 'decline' | 'cancel' } + +// Durable error and recovery record +ErrorResponsePart { + kind: 'error' + id: string + error: ErrorInfo + recovery?: { + options: ErrorRecoveryOption[] // host-provided actions + selectedOptionId?: string // durable user decision + } +} ``` `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 +368,13 @@ 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. +An error part without `recovery` is unrecoverable. When recovery is present and +`selectedOptionId` is absent, clients render its ordered options and dispatch +`chat/errorRecoverySelected` with the turn, part, and option identifiers. The +reducer records the selected option ID on the part and reopens the same turn +without adding a user message. If processing fails again, the host appends +another error part; prior errors and selections 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..d97fe71b9 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -174,9 +174,39 @@ 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 offers recovery options, a client may dispatch `chat/errorRecoverySelected` to record the selected option and 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 recovery. Its +`ErrorResponsePart` is the detailed source of truth: it contains `ErrorInfo` +and, when recovery was offered, ordered host-provided options. An error part +without recovery is unrecoverable. Recovery remains available while the +recovery record has no `selectedOptionId`. + +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. + +Recovery options are opaque actions owned by the host. They may represent +retrying the request, purchasing more quota, or another provider-specific +recovery flow. Clients render the supplied labels and return only the selected +option identifier; they do not infer behavior from identifiers or labels. + +Selecting an option with `chat/errorRecoverySelected` records its identifier +on the error part and 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 and recovery decision +in response-stream order without creating a synthetic turn or message. + +The server MUST validate and sequence the selection before invoking the +option's host behavior. A rejected or stale selection 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 +255,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/errorRecoverySelected` | An active turn exists, `turnId` is not the latest errored turn, `partId` is not an unselected recoverable error part, or `optionId` is not offered by that part | 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..93afdc214 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 recovery options describe the actions the host can perform." }, "_meta": { "type": "object", @@ -1272,7 +1272,34 @@ "type", "turnId", "duration", - "error" + "part" + ] + }, + "ChatErrorRecoverySelectedAction": { + "type": "object", + "description": "A client selected one of the host-provided recovery options on an error.\n\nThe reducer records the selected option identifier on the existing error\nresponse part and reopens the same turn without adding another message. The\nhost performs the opaque recovery behavior identified by `optionId`.", + "properties": { + "type": { + "const": "chat/errorRecoverySelected" + }, + "turnId": { + "type": "string", + "description": "Identifier of the errored turn." + }, + "partId": { + "type": "string", + "description": "Identifier of the error response part." + }, + "optionId": { + "type": "string", + "description": "Identifier of the selected recovery option." + } + }, + "required": [ + "type", + "turnId", + "partId", + "optionId" ] }, "ChatActivityChangedAction": { @@ -2145,6 +2172,9 @@ { "$ref": "#/$defs/ChatErrorAction" }, + { + "$ref": "#/$defs/ChatErrorRecoverySelectedAction" + }, { "$ref": "#/$defs/ChatActivityChangedAction" }, @@ -5113,10 +5143,6 @@ "state": { "$ref": "#/$defs/TurnState", "description": "How the turn ended" - }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details if state is `'error'`" } }, "required": [ @@ -5568,6 +5594,79 @@ "request" ] }, + "ErrorRecoveryOption": { + "type": "object", + "description": "An action the host offers to recover from a turn error.\n\nThe `id` is opaque to clients. Selecting an option with\n`chat/errorRecoverySelected` asks the host to perform the corresponding\nrecovery, such as retrying the request or starting a quota-purchase flow.", + "properties": { + "id": { + "type": "string", + "description": "Stable option identifier, returned in `chat/errorRecoverySelected`." + }, + "label": { + "type": "string", + "description": "Human-readable label displayed to the user." + }, + "description": { + "type": "string", + "description": "Optional secondary text." + }, + "recommended": { + "type": "boolean", + "description": "Whether this option is the recommended/default choice." + } + }, + "required": [ + "id", + "label" + ] + }, + "ErrorRecovery": { + "type": "object", + "description": "Recovery offered for an error.\n\nPresence of this object means the host offered recovery. `options` MUST\ncontain at least one entry with a unique `id`. Recovery is available while\n`selectedOptionId` is absent. Once a client selects an option, the reducer\nrecords its identifier and reopens the same turn. The error part remains in\nthe response stream so the failure and recovery decision stay visible in\nhistory.", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/$defs/ErrorRecoveryOption" + }, + "description": "Ordered recovery options supplied by the host." + }, + "selectedOptionId": { + "type": "string", + "description": "Identifier of the option selected by the user, absent until recovery is requested." + } + }, + "required": [ + "options" + ] + }, + "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 `recovery` is absent, the error is not recoverable. When it is present\nand `selectedOptionId` is absent, a client may select one of its host-provided\noptions with `chat/errorRecoverySelected`.", + "properties": { + "kind": { + "const": "error", + "description": "Discriminant" + }, + "id": { + "type": "string", + "description": "Stable part identifier." + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Error details." + }, + "recovery": { + "$ref": "#/$defs/ErrorRecovery", + "description": "Recovery offered by the host, if any." + } + }, + "required": [ + "kind", + "id", + "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 +7488,9 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + }, + { + "$ref": "#/$defs/ErrorResponsePart" } ] }, @@ -7649,6 +7751,9 @@ { "$ref": "#/$defs/ChatErrorAction" }, + { + "$ref": "#/$defs/ChatErrorRecoverySelectedAction" + }, { "$ref": "#/$defs/ChatActivityChangedAction" }, diff --git a/schema/commands.schema.json b/schema/commands.schema.json index e65cb0f59..4a38adaf5 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,79 @@ "request" ] }, + "ErrorRecoveryOption": { + "type": "object", + "description": "An action the host offers to recover from a turn error.\n\nThe `id` is opaque to clients. Selecting an option with\n`chat/errorRecoverySelected` asks the host to perform the corresponding\nrecovery, such as retrying the request or starting a quota-purchase flow.", + "properties": { + "id": { + "type": "string", + "description": "Stable option identifier, returned in `chat/errorRecoverySelected`." + }, + "label": { + "type": "string", + "description": "Human-readable label displayed to the user." + }, + "description": { + "type": "string", + "description": "Optional secondary text." + }, + "recommended": { + "type": "boolean", + "description": "Whether this option is the recommended/default choice." + } + }, + "required": [ + "id", + "label" + ] + }, + "ErrorRecovery": { + "type": "object", + "description": "Recovery offered for an error.\n\nPresence of this object means the host offered recovery. `options` MUST\ncontain at least one entry with a unique `id`. Recovery is available while\n`selectedOptionId` is absent. Once a client selects an option, the reducer\nrecords its identifier and reopens the same turn. The error part remains in\nthe response stream so the failure and recovery decision stay visible in\nhistory.", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/$defs/ErrorRecoveryOption" + }, + "description": "Ordered recovery options supplied by the host." + }, + "selectedOptionId": { + "type": "string", + "description": "Identifier of the option selected by the user, absent until recovery is requested." + } + }, + "required": [ + "options" + ] + }, + "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 `recovery` is absent, the error is not recoverable. When it is present\nand `selectedOptionId` is absent, a client may select one of its host-provided\noptions with `chat/errorRecoverySelected`.", + "properties": { + "kind": { + "const": "error", + "description": "Discriminant" + }, + "id": { + "type": "string", + "description": "Stable part identifier." + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Error details." + }, + "recovery": { + "$ref": "#/$defs/ErrorRecovery", + "description": "Recovery offered by the host, if any." + } + }, + "required": [ + "kind", + "id", + "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 +7188,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 +7199,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 +7705,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 recovery options describe the actions the host can perform." }, "_meta": { "type": "object", @@ -7650,7 +7719,34 @@ "type", "turnId", "duration", - "error" + "part" + ] + }, + "ChatErrorRecoverySelectedAction": { + "type": "object", + "description": "A client selected one of the host-provided recovery options on an error.\n\nThe reducer records the selected option identifier on the existing error\nresponse part and reopens the same turn without adding another message. The\nhost performs the opaque recovery behavior identified by `optionId`.", + "properties": { + "type": { + "const": "chat/errorRecoverySelected" + }, + "turnId": { + "type": "string", + "description": "Identifier of the errored turn." + }, + "partId": { + "type": "string", + "description": "Identifier of the error response part." + }, + "optionId": { + "type": "string", + "description": "Identifier of the selected recovery option." + } + }, + "required": [ + "type", + "turnId", + "partId", + "optionId" ] }, "ChatActivityChangedAction": { @@ -8609,6 +8705,9 @@ { "$ref": "#/$defs/ChatErrorAction" }, + { + "$ref": "#/$defs/ChatErrorRecoverySelectedAction" + }, { "$ref": "#/$defs/ChatActivityChangedAction" }, @@ -9237,6 +9336,9 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + }, + { + "$ref": "#/$defs/ErrorResponsePart" } ] }, diff --git a/schema/errors.schema.json b/schema/errors.schema.json index b1b9745fb..68c628999 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,79 @@ "request" ] }, + "ErrorRecoveryOption": { + "type": "object", + "description": "An action the host offers to recover from a turn error.\n\nThe `id` is opaque to clients. Selecting an option with\n`chat/errorRecoverySelected` asks the host to perform the corresponding\nrecovery, such as retrying the request or starting a quota-purchase flow.", + "properties": { + "id": { + "type": "string", + "description": "Stable option identifier, returned in `chat/errorRecoverySelected`." + }, + "label": { + "type": "string", + "description": "Human-readable label displayed to the user." + }, + "description": { + "type": "string", + "description": "Optional secondary text." + }, + "recommended": { + "type": "boolean", + "description": "Whether this option is the recommended/default choice." + } + }, + "required": [ + "id", + "label" + ] + }, + "ErrorRecovery": { + "type": "object", + "description": "Recovery offered for an error.\n\nPresence of this object means the host offered recovery. `options` MUST\ncontain at least one entry with a unique `id`. Recovery is available while\n`selectedOptionId` is absent. Once a client selects an option, the reducer\nrecords its identifier and reopens the same turn. The error part remains in\nthe response stream so the failure and recovery decision stay visible in\nhistory.", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/$defs/ErrorRecoveryOption" + }, + "description": "Ordered recovery options supplied by the host." + }, + "selectedOptionId": { + "type": "string", + "description": "Identifier of the option selected by the user, absent until recovery is requested." + } + }, + "required": [ + "options" + ] + }, + "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 `recovery` is absent, the error is not recoverable. When it is present\nand `selectedOptionId` is absent, a client may select one of its host-provided\noptions with `chat/errorRecoverySelected`.", + "properties": { + "kind": { + "const": "error", + "description": "Discriminant" + }, + "id": { + "type": "string", + "description": "Stable part identifier." + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Error details." + }, + "recovery": { + "$ref": "#/$defs/ErrorRecovery", + "description": "Recovery offered by the host, if any." + } + }, + "required": [ + "kind", + "id", + "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 +6906,9 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + }, + { + "$ref": "#/$defs/ErrorResponsePart" } ] }, @@ -7220,6 +7292,9 @@ { "$ref": "#/$defs/ChatErrorAction" }, + { + "$ref": "#/$defs/ChatErrorRecoverySelectedAction" + }, { "$ref": "#/$defs/ChatActivityChangedAction" }, @@ -8123,7 +8198,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 +8209,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 +8627,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 recovery options describe the actions the host can perform." }, "_meta": { "type": "object", @@ -8566,7 +8641,34 @@ "type", "turnId", "duration", - "error" + "part" + ] + }, + "ChatErrorRecoverySelectedAction": { + "type": "object", + "description": "A client selected one of the host-provided recovery options on an error.\n\nThe reducer records the selected option identifier on the existing error\nresponse part and reopens the same turn without adding another message. The\nhost performs the opaque recovery behavior identified by `optionId`.", + "properties": { + "type": { + "const": "chat/errorRecoverySelected" + }, + "turnId": { + "type": "string", + "description": "Identifier of the errored turn." + }, + "partId": { + "type": "string", + "description": "Identifier of the error response part." + }, + "optionId": { + "type": "string", + "description": "Identifier of the selected recovery option." + } + }, + "required": [ + "type", + "turnId", + "partId", + "optionId" ] }, "ChatActivityChangedAction": { diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index ab95e4b4c..1baa3f8c3 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,79 @@ "request" ] }, + "ErrorRecoveryOption": { + "type": "object", + "description": "An action the host offers to recover from a turn error.\n\nThe `id` is opaque to clients. Selecting an option with\n`chat/errorRecoverySelected` asks the host to perform the corresponding\nrecovery, such as retrying the request or starting a quota-purchase flow.", + "properties": { + "id": { + "type": "string", + "description": "Stable option identifier, returned in `chat/errorRecoverySelected`." + }, + "label": { + "type": "string", + "description": "Human-readable label displayed to the user." + }, + "description": { + "type": "string", + "description": "Optional secondary text." + }, + "recommended": { + "type": "boolean", + "description": "Whether this option is the recommended/default choice." + } + }, + "required": [ + "id", + "label" + ] + }, + "ErrorRecovery": { + "type": "object", + "description": "Recovery offered for an error.\n\nPresence of this object means the host offered recovery. `options` MUST\ncontain at least one entry with a unique `id`. Recovery is available while\n`selectedOptionId` is absent. Once a client selects an option, the reducer\nrecords its identifier and reopens the same turn. The error part remains in\nthe response stream so the failure and recovery decision stay visible in\nhistory.", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/$defs/ErrorRecoveryOption" + }, + "description": "Ordered recovery options supplied by the host." + }, + "selectedOptionId": { + "type": "string", + "description": "Identifier of the option selected by the user, absent until recovery is requested." + } + }, + "required": [ + "options" + ] + }, + "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 `recovery` is absent, the error is not recoverable. When it is present\nand `selectedOptionId` is absent, a client may select one of its host-provided\noptions with `chat/errorRecoverySelected`.", + "properties": { + "kind": { + "const": "error", + "description": "Discriminant" + }, + "id": { + "type": "string", + "description": "Stable part identifier." + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Error details." + }, + "recovery": { + "$ref": "#/$defs/ErrorRecovery", + "description": "Recovery offered by the host, if any." + } + }, + "required": [ + "kind", + "id", + "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 +5584,9 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + }, + { + "$ref": "#/$defs/ErrorResponsePart" } ] }, diff --git a/schema/state.schema.json b/schema/state.schema.json index d594f6b42..33e39f8ed 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,79 @@ "request" ] }, + "ErrorRecoveryOption": { + "type": "object", + "description": "An action the host offers to recover from a turn error.\n\nThe `id` is opaque to clients. Selecting an option with\n`chat/errorRecoverySelected` asks the host to perform the corresponding\nrecovery, such as retrying the request or starting a quota-purchase flow.", + "properties": { + "id": { + "type": "string", + "description": "Stable option identifier, returned in `chat/errorRecoverySelected`." + }, + "label": { + "type": "string", + "description": "Human-readable label displayed to the user." + }, + "description": { + "type": "string", + "description": "Optional secondary text." + }, + "recommended": { + "type": "boolean", + "description": "Whether this option is the recommended/default choice." + } + }, + "required": [ + "id", + "label" + ] + }, + "ErrorRecovery": { + "type": "object", + "description": "Recovery offered for an error.\n\nPresence of this object means the host offered recovery. `options` MUST\ncontain at least one entry with a unique `id`. Recovery is available while\n`selectedOptionId` is absent. Once a client selects an option, the reducer\nrecords its identifier and reopens the same turn. The error part remains in\nthe response stream so the failure and recovery decision stay visible in\nhistory.", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/$defs/ErrorRecoveryOption" + }, + "description": "Ordered recovery options supplied by the host." + }, + "selectedOptionId": { + "type": "string", + "description": "Identifier of the option selected by the user, absent until recovery is requested." + } + }, + "required": [ + "options" + ] + }, + "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 `recovery` is absent, the error is not recoverable. When it is present\nand `selectedOptionId` is absent, a client may select one of its host-provided\noptions with `chat/errorRecoverySelected`.", + "properties": { + "kind": { + "const": "error", + "description": "Discriminant" + }, + "id": { + "type": "string", + "description": "Stable part identifier." + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Error details." + }, + "recovery": { + "$ref": "#/$defs/ErrorRecovery", + "description": "Recovery offered by the host, if any." + } + }, + "required": [ + "kind", + "id", + "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 +5274,9 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + }, + { + "$ref": "#/$defs/ErrorResponsePart" } ] }, diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 34e8eabd1..71735ba33 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -763,6 +763,9 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'ReasoningResponsePart' }, { name: 'SystemNotificationResponsePart' }, { name: 'InputRequestResponsePart' }, + { name: 'ErrorRecoveryOption' }, + { name: 'ErrorRecovery' }, + { name: 'ErrorResponsePart' }, { name: 'ToolCallResult' }, { name: 'ToolCallRiskAssessmentLoadingState' }, { name: 'ToolCallRiskAssessmentCompleteState' }, @@ -842,6 +845,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 +1396,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/errorRecoverySelected', variantName: 'ChatErrorRecoverySelected', tsInterface: 'ChatErrorRecoverySelectedAction' }, { 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..910f11389 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', + 'ErrorRecoveryOption', 'ErrorRecovery', '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/errorRecoverySelected', caseName: 'ChatErrorRecoverySelected', tsInterface: 'ChatErrorRecoverySelectedAction' }, { 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..014bf80f8 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -746,6 +746,9 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'ReasoningResponsePart', omitDiscriminants: true }, { name: 'SystemNotificationResponsePart', omitDiscriminants: true }, { name: 'InputRequestResponsePart', omitDiscriminants: true }, + { name: 'ErrorRecoveryOption' }, + { name: 'ErrorRecovery' }, + { name: 'ErrorResponsePart', omitDiscriminants: true }, { name: 'ToolCallResult' }, { name: 'ToolCallRiskAssessmentLoadingState', omitDiscriminants: true }, { name: 'ToolCallRiskAssessmentCompleteState', omitDiscriminants: true }, @@ -825,6 +828,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 +1235,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/errorRecoverySelected', variantName: 'ChatErrorRecoverySelected', tsInterface: 'ChatErrorRecoverySelectedAction' }, { type: 'chat/activityChanged', variantName: 'ChatActivityChanged', tsInterface: 'ChatActivityChangedAction' }, { type: 'session/titleChanged', variantName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', variantName: 'ChatUsage', tsInterface: 'ChatUsageAction' }, @@ -1330,7 +1335,7 @@ pub struct ${scope}ToolCallConfirmedAction { 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 diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 6b486893e..8cdf809af 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', + 'ErrorRecoveryOption', 'ErrorRecovery', '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/errorRecoverySelected', caseName: 'chatErrorRecoverySelected', tsInterface: 'ChatErrorRecoverySelectedAction' }, { 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..01c37ca13 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -49,6 +49,7 @@ import type { ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, + ChatErrorRecoverySelectedAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, @@ -197,6 +198,7 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatErrorRecoverySelectedAction | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction @@ -221,6 +223,7 @@ export type ClientChatAction = | ChatToolCallResultConfirmedAction | ChatToolCallContentChangedAction | ChatTurnCancelledAction + | ChatErrorRecoverySelectedAction | 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.ChatErrorRecoverySelected]: 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..f0b54cece 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 recovery options describe the actions the host can perform. + */ + part: ErrorResponsePart; /** * Additional provider-specific metadata for this action. * @@ -502,6 +509,27 @@ export interface ChatErrorAction { _meta?: Record; } +/** + * A client selected one of the host-provided recovery options on an error. + * + * The reducer records the selected option identifier on the existing error + * response part and reopens the same turn without adding another message. The + * host performs the opaque recovery behavior identified by `optionId`. + * + * @category Chat Actions + * @version 1 + * @clientDispatchable + */ +export interface ChatErrorRecoverySelectedAction { + type: ActionType.ChatErrorRecoverySelected; + /** Identifier of the errored turn. */ + turnId: string; + /** Identifier of the error response part. */ + partId: string; + /** Identifier of the selected recovery option. */ + optionId: string; +} + /** * The activity description of this chat changed. * @@ -821,6 +849,7 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatErrorRecoverySelectedAction | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index b170cefb2..c66175403 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,23 @@ function findOpenInputRequestPart( return part.kind === ResponsePartKind.InputRequest ? { index, part } : undefined; } +function findAvailableErrorRecoveryPart( + responseParts: readonly ResponsePart[], + partId: string, +): { index: number; part: ErrorResponsePart } | undefined { + const index = responseParts.findIndex(part => + part.kind === ResponsePartKind.Error + && part.id === partId + && part.recovery !== undefined + && part.recovery.selectedOptionId === undefined, + ); + if (index < 0) { + return undefined; + } + const part = responseParts[index]; + return part.kind === ResponsePartKind.Error ? { index, part } : undefined; +} + /** Bitmask covering the mutually-exclusive activity bits (bits 0–4). */ const STATUS_ACTIVITY_MASK = (1 << 5) - 1; @@ -170,7 +188,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 +215,9 @@ function endTurn( }, }; }); + if (errorPart) { + responseParts.push(errorPart); + } const turn: Turn = { id: active.id, @@ -208,7 +229,6 @@ function endTurn( responseParts, usage: active.usage, state: turnState, - error, }; const next: ChatState = { @@ -385,6 +405,9 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st if (!state.activeTurn || state.activeTurn.id !== action.turnId) { return state; } + if (action.part.kind === ResponsePartKind.Error) { + return state; + } return { ...state, activeTurn: { @@ -400,7 +423,51 @@ 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.ChatErrorRecoverySelected: { + 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) { + return state; + } + const recoveryPart = findAvailableErrorRecoveryPart(turn.responseParts, action.partId); + if (!recoveryPart?.part.recovery) { + return state; + } + if (!recoveryPart.part.recovery.options.some(option => option.id === action.optionId)) { + return state; + } + const responseParts = [...turn.responseParts]; + responseParts[recoveryPart.index] = { + ...recoveryPart.part, + recovery: { + ...recoveryPart.part.recovery, + selectedOptionId: action.optionId, + }, + }; + 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, + 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..d601a83d0 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,69 @@ export interface InputRequestResponsePart { response?: ChatInputResponseKind; } +/** + * An action the host offers to recover from a turn error. + * + * The `id` is opaque to clients. Selecting an option with + * `chat/errorRecoverySelected` asks the host to perform the corresponding + * recovery, such as retrying the request or starting a quota-purchase flow. + * + * @category Response Parts + */ +export interface ErrorRecoveryOption { + /** Stable option identifier, returned in `chat/errorRecoverySelected`. */ + id: string; + /** Human-readable label displayed to the user. */ + label: string; + /** Optional secondary text. */ + description?: string; + /** Whether this option is the recommended/default choice. */ + recommended?: boolean; +} + +/** + * Recovery offered for an error. + * + * Presence of this object means the host offered recovery. `options` MUST + * contain at least one entry with a unique `id`. Recovery is available while + * `selectedOptionId` is absent. Once a client selects an option, the reducer + * records its identifier and reopens the same turn. The error part remains in + * the response stream so the failure and recovery decision stay visible in + * history. + * + * @category Response Parts + */ +export interface ErrorRecovery { + /** Ordered recovery options supplied by the host. */ + options: ErrorRecoveryOption[]; + /** Identifier of the option selected by the user, absent until recovery is requested. */ + selectedOptionId?: string; +} + +/** + * 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 `recovery` is absent, the error is not recoverable. When it is present + * and `selectedOptionId` is absent, a client may select one of its host-provided + * options with `chat/errorRecoverySelected`. + * + * @category Response Parts + */ +export interface ErrorResponsePart { + /** Discriminant */ + kind: ResponsePartKind.Error; + /** Stable part identifier. */ + id: string; + /** Error details. */ + error: ErrorInfo; + /** Recovery offered by the host, if any. */ + recovery?: ErrorRecovery; +} + /** * A system notification surfaced as part of the response stream. * diff --git a/types/common/actions.ts b/types/common/actions.ts index d07164129..b95fd291b 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -61,6 +61,7 @@ import type { ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, + ChatErrorRecoverySelectedAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, @@ -145,6 +146,7 @@ export const enum ActionType { ChatTurnComplete = 'chat/turnComplete', ChatTurnCancelled = 'chat/turnCancelled', ChatError = 'chat/error', + ChatErrorRecoverySelected = 'chat/errorRecoverySelected', ChatActivityChanged = 'chat/activityChanged', ChatWorkingDirectorySet = 'chat/workingDirectorySet', ChatWorkingDirectoryRemoved = 'chat/workingDirectoryRemoved', @@ -289,6 +291,7 @@ export type StateAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatErrorRecoverySelectedAction | 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..74e1b6936 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,13 @@ "type": "chat/error", "turnId": "turn-1", "duration": 8999, - "error": { - "errorType": "runtime", - "message": "Something broke" + "part": { + "kind": "error", + "id": "error-1", + "error": { + "errorType": "runtime", + "message": "Something broke" + } } } ], @@ -43,13 +47,18 @@ "kind": "user" } }, - "responseParts": [], + "responseParts": [ + { + "kind": "error", + "id": "error-1", + "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-error-recovery-reopens-turn.json b/types/test-cases/reducers/263-chat-error-recovery-reopens-turn.json new file mode 100644 index 000000000..c96f50992 --- /dev/null +++ b/types/test-cases/reducers/263-chat-error-recovery-reopens-turn.json @@ -0,0 +1,110 @@ +{ + "description": "chat/errorRecoverySelected records the selected host option and reopens the same turn", + "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", + "id": "error-1", + "error": { + "errorType": "quota", + "message": "More tokens are required" + }, + "recovery": { + "options": [ + { + "id": "retry", + "label": "Try Again" + }, + { + "id": "buy-tokens", + "label": "Buy More Tokens", + "description": "Increase the available quota", + "recommended": true + } + ] + } + } + ], + "usage": null, + "state": "error" + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/errorRecoverySelected", + "turnId": "turn-1", + "partId": "error-1", + "optionId": "buy-tokens" + } + ], + "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", + "id": "error-1", + "error": { + "errorType": "quota", + "message": "More tokens are required" + }, + "recovery": { + "options": [ + { + "id": "retry", + "label": "Try Again" + }, + { + "id": "buy-tokens", + "label": "Buy More Tokens", + "description": "Increase the available quota", + "recommended": true + } + ], + "selectedOptionId": "buy-tokens" + } + } + ], + "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-error-recovery-noop-with-active-turn.json b/types/test-cases/reducers/264-chat-error-recovery-noop-with-active-turn.json new file mode 100644 index 000000000..33299efaa --- /dev/null +++ b/types/test-cases/reducers/264-chat-error-recovery-noop-with-active-turn.json @@ -0,0 +1,50 @@ +{ + "description": "chat/errorRecoverySelected is a no-op 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/errorRecoverySelected", + "turnId": "turn-1", + "partId": "error-1", + "optionId": "retry" + } + ], + "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-error-recovery-noop-for-unknown-turn.json b/types/test-cases/reducers/265-chat-error-recovery-noop-for-unknown-turn.json new file mode 100644 index 000000000..d5cc6d9e8 --- /dev/null +++ b/types/test-cases/reducers/265-chat-error-recovery-noop-for-unknown-turn.json @@ -0,0 +1,26 @@ +{ + "description": "chat/errorRecoverySelected is a no-op 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/errorRecoverySelected", + "turnId": "turn-1", + "partId": "error-1", + "optionId": "retry" + } + ], + "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-error-recovery-noop-for-nonlatest-turn.json b/types/test-cases/reducers/266-chat-error-recovery-noop-for-nonlatest-turn.json new file mode 100644 index 000000000..3cfa66256 --- /dev/null +++ b/types/test-cases/reducers/266-chat-error-recovery-noop-for-nonlatest-turn.json @@ -0,0 +1,110 @@ +{ + "description": "chat/errorRecoverySelected is a no-op when the errored turn is not latest", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "id": "error-1", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "recovery": { + "options": [ + { + "id": "retry", + "label": "Try Again" + } + ] + } + } + ], + "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/errorRecoverySelected", + "turnId": "turn-1", + "partId": "error-1", + "optionId": "retry" + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "id": "error-1", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "recovery": { + "options": [ + { + "id": "retry", + "label": "Try Again" + } + ] + } + } + ], + "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-error-recovery-noop-without-available-option.json b/types/test-cases/reducers/267-chat-error-recovery-noop-without-available-option.json new file mode 100644 index 000000000..269acbcaf --- /dev/null +++ b/types/test-cases/reducers/267-chat-error-recovery-noop-without-available-option.json @@ -0,0 +1,154 @@ +{ + "description": "chat/errorRecoverySelected is a no-op for unavailable, already selected, and unknown options", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "id": "unrecoverable", + "error": { + "errorType": "fatal", + "message": "Cannot recover" + } + }, + { + "kind": "error", + "id": "selected", + "error": { + "errorType": "quota", + "message": "Quota exceeded" + }, + "recovery": { + "options": [ + { + "id": "buy", + "label": "Buy More Tokens" + } + ], + "selectedOptionId": "buy" + } + }, + { + "kind": "error", + "id": "available", + "error": { + "errorType": "runtime", + "message": "Try again" + }, + "recovery": { + "options": [ + { + "id": "retry", + "label": "Try Again" + } + ] + } + } + ], + "usage": null, + "state": "error" + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/errorRecoverySelected", + "turnId": "turn-1", + "partId": "missing", + "optionId": "retry" + }, + { + "type": "chat/errorRecoverySelected", + "turnId": "turn-1", + "partId": "unrecoverable", + "optionId": "retry" + }, + { + "type": "chat/errorRecoverySelected", + "turnId": "turn-1", + "partId": "selected", + "optionId": "buy" + }, + { + "type": "chat/errorRecoverySelected", + "turnId": "turn-1", + "partId": "available", + "optionId": "unknown" + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "id": "unrecoverable", + "error": { + "errorType": "fatal", + "message": "Cannot recover" + } + }, + { + "kind": "error", + "id": "selected", + "error": { + "errorType": "quota", + "message": "Quota exceeded" + }, + "recovery": { + "options": [ + { + "id": "buy", + "label": "Buy More Tokens" + } + ], + "selectedOptionId": "buy" + } + }, + { + "kind": "error", + "id": "available", + "error": { + "errorType": "runtime", + "message": "Try again" + }, + "recovery": { + "options": [ + { + "id": "retry", + "label": "Try Again" + } + ] + } + } + ], + "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-error-recovery-preserves-errors-across-retries.json b/types/test-cases/reducers/268-chat-error-recovery-preserves-errors-across-retries.json new file mode 100644 index 000000000..3ba8bc839 --- /dev/null +++ b/types/test-cases/reducers/268-chat-error-recovery-preserves-errors-across-retries.json @@ -0,0 +1,114 @@ +{ + "description": "a recovered turn keeps its prior error and selection 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", + "id": "error-1", + "error": { + "errorType": "runtime", + "message": "First failure" + }, + "recovery": { + "options": [ + { + "id": "retry", + "label": "Try Again" + } + ] + } + } + }, + { + "type": "chat/errorRecoverySelected", + "turnId": "turn-1", + "partId": "error-1", + "optionId": "retry" + }, + { + "type": "chat/error", + "turnId": "turn-1", + "duration": 2000, + "part": { + "kind": "error", + "id": "error-2", + "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", + "id": "error-1", + "error": { + "errorType": "runtime", + "message": "First failure" + }, + "recovery": { + "options": [ + { + "id": "retry", + "label": "Try Again" + } + ], + "selectedOptionId": "retry" + } + }, + { + "kind": "error", + "id": "error-2", + "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-error-recovery-completes-one-turn.json b/types/test-cases/reducers/269-chat-error-recovery-completes-one-turn.json new file mode 100644 index 000000000..1bbbaac7f --- /dev/null +++ b/types/test-cases/reducers/269-chat-error-recovery-completes-one-turn.json @@ -0,0 +1,97 @@ +{ + "description": "a recovered turn completes as one turn while preserving its error and recovery decision", + "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", + "id": "error-1", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "recovery": { + "options": [ + { + "id": "retry", + "label": "Try Again" + } + ] + } + } + ], + "usage": null, + "state": "error" + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/errorRecoverySelected", + "turnId": "turn-1", + "partId": "error-1", + "optionId": "retry" + }, + { + "type": "chat/turnComplete", + "turnId": "turn-1", + "duration": 2000 + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 2000, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "id": "error-1", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "recovery": { + "options": [ + { + "id": "retry", + "label": "Try Again" + } + ], + "selectedOptionId": "retry" + } + } + ], + "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..a9184ee40 --- /dev/null +++ b/types/test-cases/reducers/270-chat-responsepart-cannot-append-error.json @@ -0,0 +1,56 @@ +{ + "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", + "id": "error-1", + "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/version/registry.ts b/types/version/registry.ts index 205e00e88..86f1430a6 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.ChatErrorRecoverySelected]: '0.8.0', [ActionType.ChatActivityChanged]: '0.5.0', [ActionType.ChatWorkingDirectorySet]: '0.7.0', [ActionType.ChatWorkingDirectoryRemoved]: '0.7.0', From 3e6a2c116cc2c8369ffb0b07ee7d4fa7e45ce0d0 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 11 Aug 2026 15:39:01 -0700 Subject: [PATCH 2/5] chat: simplify resumable error turns Represent errors as durable response parts with an optional resumable flag and add chat/turnResume to reopen the latest errored turn without a new message.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahp/reducers.go | 39 +---- clients/go/ahptypes/actions.generated.go | 25 ++- clients/go/ahptypes/state.generated.go | 43 +---- .../microsoft/agenthostprotocol/Reducers.kt | 22 +-- .../generated/Actions.generated.kt | 24 +-- .../generated/State.generated.kt | 40 +---- clients/rust/crates/ahp-types/src/actions.rs | 25 ++- clients/rust/crates/ahp-types/src/state.rs | 50 +----- clients/rust/crates/ahp/src/reducers.rs | 21 +-- .../Generated/Actions.generated.swift | 24 +-- .../Generated/State.generated.swift | 50 +----- .../Sources/AgentHostProtocol/Reducers.swift | 24 +-- .../ReducersTests.swift | 8 +- .../20260807-error-recovery-actions.json | 4 - ...-error-response-parts-and-turn-resume.json | 4 + docs/guide/actions.md | 2 +- docs/guide/state-model.md | 18 +- docs/specification/chat-channel.md | 28 ++-- schema/actions.schema.json | 82 ++-------- schema/commands.schema.json | 80 +-------- schema/errors.schema.json | 80 +-------- schema/notifications.schema.json | 58 +------ schema/state.schema.json | 58 +------ scripts/generate-go.ts | 4 +- scripts/generate-kotlin.ts | 4 +- scripts/generate-rust.ts | 4 +- scripts/generate-swift.ts | 4 +- types/action-origin.generated.ts | 8 +- types/channels-chat/actions.ts | 21 +-- types/channels-chat/reducer.ts | 39 +---- types/channels-chat/state.ts | 51 +----- types/common/actions.ts | 6 +- ...ssion-error-finalizes-turn-with-error.json | 2 - ...=> 263-chat-turn-resume-reopens-turn.json} | 41 +---- ...at-turn-resume-noop-with-active-turn.json} | 8 +- ...at-turn-resume-noop-for-unknown-turn.json} | 8 +- ...-turn-resume-noop-for-nonlatest-turn.json} | 28 +--- ...ecovery-noop-without-available-option.json | 154 ------------------ ...urn-resume-noop-for-unresumable-error.json | 66 ++++++++ ...sume-preserves-errors-across-retries.json} | 31 +--- ...-chat-turn-resume-completes-one-turn.json} | 29 +--- ...chat-responsepart-cannot-append-error.json | 1 - types/version/registry.ts | 2 +- 43 files changed, 261 insertions(+), 1059 deletions(-) delete mode 100644 docs/.changes/20260807-error-recovery-actions.json create mode 100644 docs/.changes/20260811-error-response-parts-and-turn-resume.json rename types/test-cases/reducers/{263-chat-error-recovery-reopens-turn.json => 263-chat-turn-resume-reopens-turn.json} (58%) rename types/test-cases/reducers/{264-chat-error-recovery-noop-with-active-turn.json => 264-chat-turn-resume-noop-with-active-turn.json} (81%) rename types/test-cases/reducers/{265-chat-error-recovery-noop-for-unknown-turn.json => 265-chat-turn-resume-noop-for-unknown-turn.json} (65%) rename types/test-cases/reducers/{266-chat-error-recovery-noop-for-nonlatest-turn.json => 266-chat-turn-resume-noop-for-nonlatest-turn.json} (72%) delete mode 100644 types/test-cases/reducers/267-chat-error-recovery-noop-without-available-option.json create mode 100644 types/test-cases/reducers/267-chat-turn-resume-noop-for-unresumable-error.json rename types/test-cases/reducers/{268-chat-error-recovery-preserves-errors-across-retries.json => 268-chat-turn-resume-preserves-errors-across-retries.json} (71%) rename types/test-cases/reducers/{269-chat-error-recovery-completes-one-turn.json => 269-chat-turn-resume-completes-one-turn.json} (67%) diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 6c14e70cc..b22cfefc2 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -190,14 +190,12 @@ func hasOpenInputRequest(state *ahptypes.ChatState) bool { return false } -func findAvailableErrorRecoveryPart(responseParts []ahptypes.ResponsePart, partID string) (int, *ahptypes.ErrorResponsePart) { - for i := range responseParts { - part, ok := responseParts[i].Value.(*ahptypes.ErrorResponsePart) - if ok && part.Id == partID && part.Recovery != nil && part.Recovery.SelectedOptionId == nil { - return i, part - } +func hasResumableError(turn *ahptypes.Turn) bool { + if len(turn.ResponseParts) == 0 { + return false } - return -1, nil + 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 { @@ -539,36 +537,15 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R case *ahptypes.ChatErrorAction: errStatus := ahptypes.SessionStatusError return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateError, &errStatus, &a.Part) - case *ahptypes.ChatErrorRecoverySelectedAction: + 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 { - return ReduceOutcomeNoOp - } - partIndex, recoveryPart := findAvailableErrorRecoveryPart(turn.ResponseParts, a.PartId) - if recoveryPart == nil { - return ReduceOutcomeNoOp - } - optionAvailable := false - for i := range recoveryPart.Recovery.Options { - if recoveryPart.Recovery.Options[i].Id == a.OptionId { - optionAvailable = true - break - } - } - if !optionAvailable { + if turn.Id != a.TurnId || turn.State != ahptypes.TurnStateError || !hasResumableError(&turn) { return ReduceOutcomeNoOp } - recovery := *recoveryPart.Recovery - selectedOptionID := a.OptionId - recovery.SelectedOptionId = &selectedOptionID - updatedPart := *recoveryPart - updatedPart.Recovery = &recovery - responseParts := append([]ahptypes.ResponsePart(nil), turn.ResponseParts...) - responseParts[partIndex] = ahptypes.ResponsePart{Value: &updatedPart} startedAt := state.ModifiedAt if turn.StartedAt != nil { @@ -579,7 +556,7 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R Id: turn.Id, StartedAt: startedAt, Message: turn.Message, - ResponseParts: responseParts, + ResponseParts: turn.ResponseParts, Usage: turn.Usage, } state.Status = withStatusFlag(summaryStatus(state, nil), ahptypes.SessionStatusIsRead, false) diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index 0aa022e8b..288e742d8 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -42,7 +42,7 @@ const ( ActionTypeChatTurnComplete ActionType = "chat/turnComplete" ActionTypeChatTurnCancelled ActionType = "chat/turnCancelled" ActionTypeChatError ActionType = "chat/error" - ActionTypeChatErrorRecoverySelected ActionType = "chat/errorRecoverySelected" + ActionTypeChatTurnResume ActionType = "chat/turnResume" ActionTypeChatActivityChanged ActionType = "chat/activityChanged" ActionTypeChatWorkingDirectorySet ActionType = "chat/workingDirectorySet" ActionTypeChatWorkingDirectoryRemoved ActionType = "chat/workingDirectoryRemoved" @@ -594,7 +594,7 @@ type ChatErrorAction struct { // data. Duration int64 `json:"duration"` // Error part to append to the response stream before finalizing the turn. - // Its optional recovery options describe the actions the host can perform. + // Its optional `resumable` flag indicates whether the turn can be resumed. Part ErrorResponsePart `json:"part"` // Additional provider-specific metadata for this action. // @@ -606,19 +606,16 @@ type ChatErrorAction struct { Meta map[string]json.RawMessage `json:"_meta,omitempty"` } -// A client selected one of the host-provided recovery options on an error. +// Resumes the latest errored turn without adding another message. // -// The reducer records the selected option identifier on the existing error -// response part and reopens the same turn without adding another message. The -// host performs the opaque recovery behavior identified by `optionId`. -type ChatErrorRecoverySelectedAction struct { +// 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"` - // Identifier of the error response part. - PartId string `json:"partId"` - // Identifier of the selected recovery option. - OptionId string `json:"optionId"` } // The activity description of this chat changed. @@ -1535,7 +1532,7 @@ func (*ChatToolCallAuthResolvedAction) isStateAction() {} func (*ChatTurnCompleteAction) isStateAction() {} func (*ChatTurnCancelledAction) isStateAction() {} func (*ChatErrorAction) isStateAction() {} -func (*ChatErrorRecoverySelectedAction) isStateAction() {} +func (*ChatTurnResumeAction) isStateAction() {} func (*ChatActivityChangedAction) isStateAction() {} func (*SessionTitleChangedAction) isStateAction() {} func (*ChatUsageAction) isStateAction() {} @@ -1756,8 +1753,8 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value - case "chat/errorRecoverySelected": - var value ChatErrorRecoverySelectedAction + case "chat/turnResume": + var value ChatTurnResumeAction if err := json.Unmarshal(data, &value); err != nil { return err } diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 3f19d8ad6..b3143b2be 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -1857,55 +1857,22 @@ type InputRequestResponsePart struct { Response *ChatInputResponseKind `json:"response,omitempty"` } -// An action the host offers to recover from a turn error. -// -// The `id` is opaque to clients. Selecting an option with -// `chat/errorRecoverySelected` asks the host to perform the corresponding -// recovery, such as retrying the request or starting a quota-purchase flow. -type ErrorRecoveryOption struct { - // Stable option identifier, returned in `chat/errorRecoverySelected`. - Id string `json:"id"` - // Human-readable label displayed to the user. - Label string `json:"label"` - // Optional secondary text. - Description *string `json:"description,omitempty"` - // Whether this option is the recommended/default choice. - Recommended *bool `json:"recommended,omitempty"` -} - -// Recovery offered for an error. -// -// Presence of this object means the host offered recovery. `options` MUST -// contain at least one entry with a unique `id`. Recovery is available while -// `selectedOptionId` is absent. Once a client selects an option, the reducer -// records its identifier and reopens the same turn. The error part remains in -// the response stream so the failure and recovery decision stay visible in -// history. -type ErrorRecovery struct { - // Ordered recovery options supplied by the host. - Options []ErrorRecoveryOption `json:"options"` - // Identifier of the option selected by the user, absent until recovery is requested. - SelectedOptionId *string `json:"selectedOptionId,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 `recovery` is absent, the error is not recoverable. When it is present -// and `selectedOptionId` is absent, a client may select one of its host-provided -// options with `chat/errorRecoverySelected`. +// When {@link resumable} is present, 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"` - // Stable part identifier. - Id string `json:"id"` // Error details. Error ErrorInfo `json:"error"` - // Recovery offered by the host, if any. - Recovery *ErrorRecovery `json:"recovery,omitempty"` + // Whether the host can resume the turn from this error. + Resumable *bool `json:"resumable,omitempty"` } // Tool execution result details, available after execution completes. 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 b30bc8566..863ff16d1 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -885,7 +885,7 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when is StateActionChatError -> endTurn(state, action.value.turnId, action.value.duration, TurnState.ERROR, SessionStatus.ERROR, action.value.part) - is StateActionChatErrorRecoverySelected -> { + is StateActionChatTurnResume -> { val a = action.value if (state.activeTurn != null || state.turns.isEmpty()) { state @@ -895,31 +895,17 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when if (turn.id != a.turnId || turn.state != TurnState.ERROR) { state } else { - val recoveryPartIndex = turn.responseParts.indexOfFirst { part -> - part is ResponsePartError && - part.value.id == a.partId && - part.value.recovery != null && - part.value.recovery.selectedOptionId == null - } - val recoveryPart = turn.responseParts.getOrNull(recoveryPartIndex) as? ResponsePartError - val recovery = recoveryPart?.value?.recovery - val hasSelectedOption = recovery?.options?.any { it.id == a.optionId } == true - if (recoveryPart == null || recovery == null || !hasSelectedOption) { + val errorPart = turn.responseParts.lastOrNull() as? ResponsePartError + if (errorPart?.value?.resumable != true) { state } else { - val responseParts = turn.responseParts.toMutableList() - responseParts[recoveryPartIndex] = ResponsePartError( - recoveryPart.value.copy( - recovery = recovery.copy(selectedOptionId = a.optionId), - ), - ) val withTurn = state.copy( turns = state.turns.dropLast(1), activeTurn = ActiveTurn( id = turn.id, startedAt = turn.startedAt ?: state.modifiedAt, message = turn.message, - responseParts = responseParts, + responseParts = turn.responseParts, usage = turn.usage, ), ) 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 0cd16acf4..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,8 +72,8 @@ enum class ActionType { CHAT_TURN_CANCELLED, @SerialName("chat/error") CHAT_ERROR, - @SerialName("chat/errorRecoverySelected") - CHAT_ERROR_RECOVERY_SELECTED, + @SerialName("chat/turnResume") + CHAT_TURN_RESUME, @SerialName("chat/activityChanged") CHAT_ACTIVITY_CHANGED, @SerialName("chat/workingDirectorySet") @@ -754,7 +754,7 @@ data class ChatErrorAction( val duration: Long, /** * Error part to append to the response stream before finalizing the turn. - * Its optional recovery options describe the actions the host can perform. + * Its optional `resumable` flag indicates whether the turn can be resumed. */ val part: ErrorResponsePart, /** @@ -771,20 +771,12 @@ data class ChatErrorAction( ) @Serializable -data class ChatErrorRecoverySelectedAction( +data class ChatTurnResumeAction( val type: ActionType, /** * Identifier of the errored turn. */ - val turnId: String, - /** - * Identifier of the error response part. - */ - val partId: String, - /** - * Identifier of the selected recovery option. - */ - val optionId: String + val turnId: String ) @Serializable @@ -1580,7 +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 StateActionChatErrorRecoverySelected(val value: ChatErrorRecoverySelectedAction) : 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 @@ -1681,7 +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/errorRecoverySelected" -> StateActionChatErrorRecoverySelected(input.json.decodeFromJsonElement(ChatErrorRecoverySelectedAction.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)) @@ -1775,7 +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 StateActionChatErrorRecoverySelected -> output.json.encodeToJsonElement(ChatErrorRecoverySelectedAction.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 25a26668c..23564fbb9 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 @@ -2509,56 +2509,20 @@ data class InputRequestResponsePart( val response: ChatInputResponseKind? = null ) -@Serializable -data class ErrorRecoveryOption( - /** - * Stable option identifier, returned in `chat/errorRecoverySelected`. - */ - val id: String, - /** - * Human-readable label displayed to the user. - */ - val label: String, - /** - * Optional secondary text. - */ - val description: String? = null, - /** - * Whether this option is the recommended/default choice. - */ - val recommended: Boolean? = null -) - -@Serializable -data class ErrorRecovery( - /** - * Ordered recovery options supplied by the host. - */ - val options: List, - /** - * Identifier of the option selected by the user, absent until recovery is requested. - */ - val selectedOptionId: String? = null -) - @Serializable data class ErrorResponsePart( /** * Discriminant */ val kind: ResponsePartKind, - /** - * Stable part identifier. - */ - val id: String, /** * Error details. */ val error: ErrorInfo, /** - * Recovery offered by the host, if any. + * Whether the host can resume the turn from this error. */ - val recovery: ErrorRecovery? = null + val resumable: Boolean? = null ) @Serializable diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 5e37da1c5..f1f16a8b5 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -75,8 +75,8 @@ pub enum ActionType { ChatTurnCancelled, #[serde(rename = "chat/error")] ChatError, - #[serde(rename = "chat/errorRecoverySelected")] - ChatErrorRecoverySelected, + #[serde(rename = "chat/turnResume")] + ChatTurnResume, #[serde(rename = "chat/activityChanged")] ChatActivityChanged, #[serde(rename = "chat/workingDirectorySet")] @@ -768,7 +768,7 @@ pub struct ChatErrorAction { /// data. pub duration: i64, /// Error part to append to the response stream before finalizing the turn. - /// Its optional recovery options describe the actions the host can perform. + /// Its optional `resumable` flag indicates whether the turn can be resumed. pub part: ErrorResponsePart, /// Additional provider-specific metadata for this action. /// @@ -781,20 +781,17 @@ pub struct ChatErrorAction { pub meta: Option, } -/// A client selected one of the host-provided recovery options on an error. +/// Resumes the latest errored turn without adding another message. /// -/// The reducer records the selected option identifier on the existing error -/// response part and reopens the same turn without adding another message. The -/// host performs the opaque recovery behavior identified by `optionId`. +/// 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 ChatErrorRecoverySelectedAction { +pub struct ChatTurnResumeAction { /// Identifier of the errored turn. pub turn_id: String, - /// Identifier of the error response part. - pub part_id: String, - /// Identifier of the selected recovery option. - pub option_id: String, } /// The activity description of this chat changed. @@ -1850,8 +1847,8 @@ pub enum StateAction { ChatTurnCancelled(ChatTurnCancelledAction), #[serde(rename = "chat/error")] ChatError(ChatErrorAction), - #[serde(rename = "chat/errorRecoverySelected")] - ChatErrorRecoverySelected(ChatErrorRecoverySelectedAction), + #[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 13cbf43c4..ecbe95ab7 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -2284,63 +2284,23 @@ pub struct InputRequestResponsePart { pub response: Option, } -/// An action the host offers to recover from a turn error. -/// -/// The `id` is opaque to clients. Selecting an option with -/// `chat/errorRecoverySelected` asks the host to perform the corresponding -/// recovery, such as retrying the request or starting a quota-purchase flow. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ErrorRecoveryOption { - /// Stable option identifier, returned in `chat/errorRecoverySelected`. - pub id: String, - /// Human-readable label displayed to the user. - pub label: String, - /// Optional secondary text. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - /// Whether this option is the recommended/default choice. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub recommended: Option, -} - -/// Recovery offered for an error. -/// -/// Presence of this object means the host offered recovery. `options` MUST -/// contain at least one entry with a unique `id`. Recovery is available while -/// `selectedOptionId` is absent. Once a client selects an option, the reducer -/// records its identifier and reopens the same turn. The error part remains in -/// the response stream so the failure and recovery decision stay visible in -/// history. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ErrorRecovery { - /// Ordered recovery options supplied by the host. - pub options: Vec, - /// Identifier of the option selected by the user, absent until recovery is requested. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub selected_option_id: 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 `recovery` is absent, the error is not recoverable. When it is present -/// and `selectedOptionId` is absent, a client may select one of its host-provided -/// options with `chat/errorRecoverySelected`. +/// When {@link resumable} is present, 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 { - /// Stable part identifier. - pub id: String, /// Error details. pub error: ErrorInfo, - /// Recovery offered by the host, if any. + /// Whether the host can resume the turn from this error. #[serde(default, skip_serializing_if = "Option::is_none")] - pub recovery: Option, + pub resumable: Option, } /// Tool execution result details, available after execution completes. diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 5e145bcaa..7ea11906e 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -599,8 +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::Error(error) => Some(error.id.clone()), - ResponsePart::ContentRef(_) + ResponsePart::Error(_) + | ResponsePart::ContentRef(_) | ResponsePart::SystemNotification(_) | ResponsePart::InputRequest(_) | ResponsePart::Unknown(_) => None, @@ -1001,7 +1001,7 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu Some(SessionStatus::Error), Some(a.part.clone()), ), - StateAction::ChatErrorRecoverySelected(a) => { + StateAction::ChatTurnResume(a) => { if state.active_turn.is_some() { return ReduceOutcome::NoOp; } @@ -1011,23 +1011,12 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu if turn.id != a.turn_id || turn.state != TurnState::Error { return ReduceOutcome::NoOp; } - let Some(recovery) = turn.response_parts.iter_mut().find_map(|part| match part { - ResponsePart::Error(error) if error.id == a.part_id => error - .recovery - .as_mut() - .filter(|recovery| recovery.selected_option_id.is_none()), - _ => None, - }) else { + let Some(ResponsePart::Error(error)) = turn.response_parts.last() else { return ReduceOutcome::NoOp; }; - if !recovery - .options - .iter() - .any(|option| option.id == a.option_id) - { + if error.resumable != Some(true) { return ReduceOutcome::NoOp; } - recovery.selected_option_id = Some(a.option_id.clone()); let turn = state.turns.pop().unwrap(); state.active_turn = Some(ActiveTurn { diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 5546d8ed4..356f0ae24 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -29,7 +29,7 @@ public enum ActionType: String, Codable, Sendable { case chatTurnComplete = "chat/turnComplete" case chatTurnCancelled = "chat/turnCancelled" case chatError = "chat/error" - case chatErrorRecoverySelected = "chat/errorRecoverySelected" + case chatTurnResume = "chat/turnResume" case chatActivityChanged = "chat/activityChanged" case chatWorkingDirectorySet = "chat/workingDirectorySet" case chatWorkingDirectoryRemoved = "chat/workingDirectoryRemoved" @@ -895,7 +895,7 @@ public struct ChatErrorAction: Codable, Sendable { /// data. public var duration: Int /// Error part to append to the response stream before finalizing the turn. - /// Its optional recovery options describe the actions the host can perform. + /// Its optional `resumable` flag indicates whether the turn can be resumed. public var part: ErrorResponsePart /// Additional provider-specific metadata for this action. /// @@ -929,25 +929,17 @@ public struct ChatErrorAction: Codable, Sendable { } } -public struct ChatErrorRecoverySelectedAction: Codable, Sendable { +public struct ChatTurnResumeAction: Codable, Sendable { public var type: ActionType /// Identifier of the errored turn. public var turnId: String - /// Identifier of the error response part. - public var partId: String - /// Identifier of the selected recovery option. - public var optionId: String public init( type: ActionType, - turnId: String, - partId: String, - optionId: String + turnId: String ) { self.type = type self.turnId = turnId - self.partId = partId - self.optionId = optionId } } @@ -2064,7 +2056,7 @@ public enum StateAction: Codable, Sendable { case chatTurnComplete(ChatTurnCompleteAction) case chatTurnCancelled(ChatTurnCancelledAction) case chatError(ChatErrorAction) - case chatErrorRecoverySelected(ChatErrorRecoverySelectedAction) + case chatTurnResume(ChatTurnResumeAction) case chatActivityChanged(ChatActivityChangedAction) case sessionTitleChanged(SessionTitleChangedAction) case chatUsage(ChatUsageAction) @@ -2185,8 +2177,8 @@ public enum StateAction: Codable, Sendable { self = .chatTurnCancelled(try ChatTurnCancelledAction(from: decoder)) case "chat/error": self = .chatError(try ChatErrorAction(from: decoder)) - case "chat/errorRecoverySelected": - self = .chatErrorRecoverySelected(try ChatErrorRecoverySelectedAction(from: decoder)) + case "chat/turnResume": + self = .chatTurnResume(try ChatTurnResumeAction(from: decoder)) case "chat/activityChanged": self = .chatActivityChanged(try ChatActivityChangedAction(from: decoder)) case "session/titleChanged": @@ -2341,7 +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 .chatErrorRecoverySelected(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 7f240615f..938b69473 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -2527,64 +2527,22 @@ public struct InputRequestResponsePart: Codable, Sendable { } } -public struct ErrorRecoveryOption: Codable, Sendable { - /// Stable option identifier, returned in `chat/errorRecoverySelected`. - public var id: String - /// Human-readable label displayed to the user. - public var label: String - /// Optional secondary text. - public var description: String? - /// Whether this option is the recommended/default choice. - public var recommended: Bool? - - public init( - id: String, - label: String, - description: String? = nil, - recommended: Bool? = nil - ) { - self.id = id - self.label = label - self.description = description - self.recommended = recommended - } -} - -public struct ErrorRecovery: Codable, Sendable { - /// Ordered recovery options supplied by the host. - public var options: [ErrorRecoveryOption] - /// Identifier of the option selected by the user, absent until recovery is requested. - public var selectedOptionId: String? - - public init( - options: [ErrorRecoveryOption], - selectedOptionId: String? = nil - ) { - self.options = options - self.selectedOptionId = selectedOptionId - } -} - public struct ErrorResponsePart: Codable, Sendable { /// Discriminant public var kind: ResponsePartKind - /// Stable part identifier. - public var id: String /// Error details. public var error: ErrorInfo - /// Recovery offered by the host, if any. - public var recovery: ErrorRecovery? + /// Whether the host can resume the turn from this error. + public var resumable: Bool? public init( kind: ResponsePartKind, - id: String, error: ErrorInfo, - recovery: ErrorRecovery? = nil + resumable: Bool? = nil ) { self.kind = kind - self.id = id self.error = error - self.recovery = recovery + self.resumable = resumable } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 7632e15ce..617d4bf66 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -181,36 +181,24 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { case .chatError(let a): return endTurn(state: state, turnId: a.turnId, duration: a.duration, turnState: .error, terminalStatus: .error, errorPart: a.part) - case .chatErrorRecoverySelected(let a): + case .chatTurnResume(let a): guard state.activeTurn == nil, let turn = state.turns.last, turn.id == a.turnId, turn.state == .error, - let recoveryPartIndex = turn.responseParts.firstIndex(where: { part in - guard case .error(let errorPart) = part else { return false } - return errorPart.id == a.partId - && errorPart.recovery != nil - && errorPart.recovery?.selectedOptionId == nil - }), - case .error(var errorPart) = turn.responseParts[recoveryPartIndex], - var recovery = errorPart.recovery, - recovery.options.contains(where: { $0.id == a.optionId }) + case .error(let errorPart) = turn.responseParts.last, + errorPart.resumable == true else { return state } - recovery.selectedOptionId = a.optionId - errorPart.recovery = recovery - var responseParts = turn.responseParts - responseParts[recoveryPartIndex] = .error(errorPart) - var next = state next.turns.removeLast() next.activeTurn = ActiveTurn( id: turn.id, startedAt: turn.startedAt ?? state.modifiedAt, message: turn.message, - responseParts: responseParts, + responseParts: turn.responseParts, usage: turn.usage ) next.status = withStatusFlag(chatSummaryStatus(next), .isRead, false) @@ -923,7 +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/errorRecoverySelected", + "chat/turnResume", "chat/toolCallConfirmed", "chat/toolCallComplete", "chat/toolCallResultConfirmed", @@ -945,7 +933,7 @@ public let clientDispatchableActions: Set = [ /// Checks whether an action may be dispatched by a client. public func isClientDispatchable(_ action: StateAction) -> Bool { switch action { - case .chatTurnStarted, .chatErrorRecoverySelected, + case .chatTurnStarted, .chatTurnResume, .chatToolCallConfirmed, .chatToolCallComplete, .chatToolCallResultConfirmed, .chatTurnCancelled, .sessionActiveClientSet, diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift index 4145e5b55..47cf9dde2 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift @@ -93,11 +93,9 @@ final class ReducersTests: XCTestCase { type: .chatTurnStarted, turnId: T, startedAt: "2026-07-09T20:00:00.000Z", message: Message(text: "Hello", origin: MessageOrigin(kind: .user)) )), - .chatErrorRecoverySelected(ChatErrorRecoverySelectedAction( - type: .chatErrorRecoverySelected, - turnId: T, - partId: "error-1", - optionId: "retry" + .chatTurnResume(ChatTurnResumeAction( + type: .chatTurnResume, + turnId: T )), ] XCTAssertTrue(actions.allSatisfy(isClientDispatchable)) diff --git a/docs/.changes/20260807-error-recovery-actions.json b/docs/.changes/20260807-error-recovery-actions.json deleted file mode 100644 index f539d87d3..000000000 --- a/docs/.changes/20260807-error-recovery-actions.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "changed", - "message": "Turn errors are durable response parts with optional host-provided recovery actions selected through `chat/errorRecoverySelected`." -} 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 81306a924..1562beffd 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -57,7 +57,7 @@ When a client dispatches an action, the server applies it to the state and also | `chat/turnComplete` | No | Turn finished (assistant idle) | | `chat/turnCancelled` | **Yes** | Turn was aborted; server stops processing | | `chat/error` | No | Error during turn processing; appends an error response part and ends the turn | -| `chat/errorRecoverySelected` | **Yes** | User selected a host-provided recovery option; records the choice and continues the same 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 d433ef835..921d9e8e6 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -348,15 +348,11 @@ InputRequestResponsePart { response: ChatInputResponseKind // 'accept' | 'decline' | 'cancel' } -// Durable error and recovery record +// Durable error record ErrorResponsePart { kind: 'error' - id: string error: ErrorInfo - recovery?: { - options: ErrorRecoveryOption[] // host-provided actions - selectedOptionId?: string // durable user decision - } + resumable?: true } ``` @@ -368,12 +364,10 @@ 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. -An error part without `recovery` is unrecoverable. When recovery is present and -`selectedOptionId` is absent, clients render its ordered options and dispatch -`chat/errorRecoverySelected` with the turn, part, and option identifiers. The -reducer records the selected option ID on the part and reopens the same turn -without adding a user message. If processing fails again, the host appends -another error part; prior errors and selections remain in stream order. +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 diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index d97fe71b9..2ac9fbee6 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -174,38 +174,30 @@ 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 offers recovery options, a client may dispatch `chat/errorRecoverySelected` to record the selected option and continue the same turn without another user message. +- A `chat/error` appends an error response part before setting the turn state to `error`. When that part is resumable, 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 recovery. Its +top-level signal for clients that do not implement resume. Its `ErrorResponsePart` is the detailed source of truth: it contains `ErrorInfo` -and, when recovery was offered, ordered host-provided options. An error part -without recovery is unrecoverable. Recovery remains available while the -recovery record has no `selectedOptionId`. +and may declare the turn resumable. 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. -Recovery options are opaque actions owned by the host. They may represent -retrying the request, purchasing more quota, or another provider-specific -recovery flow. Clients render the supplied labels and return only the selected -option identifier; they do not infer behavior from identifiers or labels. - -Selecting an option with `chat/errorRecoverySelected` records its identifier -on the error part and reopens the same turn. The original message, turn +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 and recovery decision -in response-stream order without creating a synthetic turn or message. +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 selection before invoking the -option's host behavior. A rejected or stale selection MUST NOT produce side -effects. +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 @@ -255,7 +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/errorRecoverySelected` | An active turn exists, `turnId` is not the latest errored turn, `partId` is not an unselected recoverable error part, or `optionId` is not offered by that part | 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 93afdc214..b38d3b1bb 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -1260,7 +1260,7 @@ }, "part": { "$ref": "#/$defs/ErrorResponsePart", - "description": "Error part to append to the response stream before finalizing the turn.\nIts optional recovery options describe the actions the host can perform." + "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", @@ -1275,31 +1275,21 @@ "part" ] }, - "ChatErrorRecoverySelectedAction": { + "ChatTurnResumeAction": { "type": "object", - "description": "A client selected one of the host-provided recovery options on an error.\n\nThe reducer records the selected option identifier on the existing error\nresponse part and reopens the same turn without adding another message. The\nhost performs the opaque recovery behavior identified by `optionId`.", + "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/errorRecoverySelected" + "const": "chat/turnResume" }, "turnId": { "type": "string", "description": "Identifier of the errored turn." - }, - "partId": { - "type": "string", - "description": "Identifier of the error response part." - }, - "optionId": { - "type": "string", - "description": "Identifier of the selected recovery option." } }, "required": [ "type", - "turnId", - "partId", - "optionId" + "turnId" ] }, "ChatActivityChangedAction": { @@ -2173,7 +2163,7 @@ "$ref": "#/$defs/ChatErrorAction" }, { - "$ref": "#/$defs/ChatErrorRecoverySelectedAction" + "$ref": "#/$defs/ChatTurnResumeAction" }, { "$ref": "#/$defs/ChatActivityChangedAction" @@ -5594,76 +5584,24 @@ "request" ] }, - "ErrorRecoveryOption": { - "type": "object", - "description": "An action the host offers to recover from a turn error.\n\nThe `id` is opaque to clients. Selecting an option with\n`chat/errorRecoverySelected` asks the host to perform the corresponding\nrecovery, such as retrying the request or starting a quota-purchase flow.", - "properties": { - "id": { - "type": "string", - "description": "Stable option identifier, returned in `chat/errorRecoverySelected`." - }, - "label": { - "type": "string", - "description": "Human-readable label displayed to the user." - }, - "description": { - "type": "string", - "description": "Optional secondary text." - }, - "recommended": { - "type": "boolean", - "description": "Whether this option is the recommended/default choice." - } - }, - "required": [ - "id", - "label" - ] - }, - "ErrorRecovery": { - "type": "object", - "description": "Recovery offered for an error.\n\nPresence of this object means the host offered recovery. `options` MUST\ncontain at least one entry with a unique `id`. Recovery is available while\n`selectedOptionId` is absent. Once a client selects an option, the reducer\nrecords its identifier and reopens the same turn. The error part remains in\nthe response stream so the failure and recovery decision stay visible in\nhistory.", - "properties": { - "options": { - "type": "array", - "items": { - "$ref": "#/$defs/ErrorRecoveryOption" - }, - "description": "Ordered recovery options supplied by the host." - }, - "selectedOptionId": { - "type": "string", - "description": "Identifier of the option selected by the user, absent until recovery is requested." - } - }, - "required": [ - "options" - ] - }, "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 `recovery` is absent, the error is not recoverable. When it is present\nand `selectedOptionId` is absent, a client may select one of its host-provided\noptions with `chat/errorRecoverySelected`.", + "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 present, 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" }, - "id": { - "type": "string", - "description": "Stable part identifier." - }, "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details." }, - "recovery": { - "$ref": "#/$defs/ErrorRecovery", - "description": "Recovery offered by the host, if any." + "resumable": { + "description": "Whether the host can resume the turn from this error." } }, "required": [ "kind", - "id", "error" ] }, @@ -7752,7 +7690,7 @@ "$ref": "#/$defs/ChatErrorAction" }, { - "$ref": "#/$defs/ChatErrorRecoverySelectedAction" + "$ref": "#/$defs/ChatTurnResumeAction" }, { "$ref": "#/$defs/ChatActivityChangedAction" diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 4a38adaf5..8bf103aec 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -4873,76 +4873,24 @@ "request" ] }, - "ErrorRecoveryOption": { - "type": "object", - "description": "An action the host offers to recover from a turn error.\n\nThe `id` is opaque to clients. Selecting an option with\n`chat/errorRecoverySelected` asks the host to perform the corresponding\nrecovery, such as retrying the request or starting a quota-purchase flow.", - "properties": { - "id": { - "type": "string", - "description": "Stable option identifier, returned in `chat/errorRecoverySelected`." - }, - "label": { - "type": "string", - "description": "Human-readable label displayed to the user." - }, - "description": { - "type": "string", - "description": "Optional secondary text." - }, - "recommended": { - "type": "boolean", - "description": "Whether this option is the recommended/default choice." - } - }, - "required": [ - "id", - "label" - ] - }, - "ErrorRecovery": { - "type": "object", - "description": "Recovery offered for an error.\n\nPresence of this object means the host offered recovery. `options` MUST\ncontain at least one entry with a unique `id`. Recovery is available while\n`selectedOptionId` is absent. Once a client selects an option, the reducer\nrecords its identifier and reopens the same turn. The error part remains in\nthe response stream so the failure and recovery decision stay visible in\nhistory.", - "properties": { - "options": { - "type": "array", - "items": { - "$ref": "#/$defs/ErrorRecoveryOption" - }, - "description": "Ordered recovery options supplied by the host." - }, - "selectedOptionId": { - "type": "string", - "description": "Identifier of the option selected by the user, absent until recovery is requested." - } - }, - "required": [ - "options" - ] - }, "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 `recovery` is absent, the error is not recoverable. When it is present\nand `selectedOptionId` is absent, a client may select one of its host-provided\noptions with `chat/errorRecoverySelected`.", + "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 present, 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" }, - "id": { - "type": "string", - "description": "Stable part identifier." - }, "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details." }, - "recovery": { - "$ref": "#/$defs/ErrorRecovery", - "description": "Recovery offered by the host, if any." + "resumable": { + "description": "Whether the host can resume the turn from this error." } }, "required": [ "kind", - "id", "error" ] }, @@ -7707,7 +7655,7 @@ }, "part": { "$ref": "#/$defs/ErrorResponsePart", - "description": "Error part to append to the response stream before finalizing the turn.\nIts optional recovery options describe the actions the host can perform." + "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", @@ -7722,31 +7670,21 @@ "part" ] }, - "ChatErrorRecoverySelectedAction": { + "ChatTurnResumeAction": { "type": "object", - "description": "A client selected one of the host-provided recovery options on an error.\n\nThe reducer records the selected option identifier on the existing error\nresponse part and reopens the same turn without adding another message. The\nhost performs the opaque recovery behavior identified by `optionId`.", + "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/errorRecoverySelected" + "const": "chat/turnResume" }, "turnId": { "type": "string", "description": "Identifier of the errored turn." - }, - "partId": { - "type": "string", - "description": "Identifier of the error response part." - }, - "optionId": { - "type": "string", - "description": "Identifier of the selected recovery option." } }, "required": [ "type", - "turnId", - "partId", - "optionId" + "turnId" ] }, "ChatActivityChangedAction": { @@ -8706,7 +8644,7 @@ "$ref": "#/$defs/ChatErrorAction" }, { - "$ref": "#/$defs/ChatErrorRecoverySelectedAction" + "$ref": "#/$defs/ChatTurnResumeAction" }, { "$ref": "#/$defs/ChatActivityChangedAction" diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 68c628999..f6d64e1b9 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -3469,76 +3469,24 @@ "request" ] }, - "ErrorRecoveryOption": { - "type": "object", - "description": "An action the host offers to recover from a turn error.\n\nThe `id` is opaque to clients. Selecting an option with\n`chat/errorRecoverySelected` asks the host to perform the corresponding\nrecovery, such as retrying the request or starting a quota-purchase flow.", - "properties": { - "id": { - "type": "string", - "description": "Stable option identifier, returned in `chat/errorRecoverySelected`." - }, - "label": { - "type": "string", - "description": "Human-readable label displayed to the user." - }, - "description": { - "type": "string", - "description": "Optional secondary text." - }, - "recommended": { - "type": "boolean", - "description": "Whether this option is the recommended/default choice." - } - }, - "required": [ - "id", - "label" - ] - }, - "ErrorRecovery": { - "type": "object", - "description": "Recovery offered for an error.\n\nPresence of this object means the host offered recovery. `options` MUST\ncontain at least one entry with a unique `id`. Recovery is available while\n`selectedOptionId` is absent. Once a client selects an option, the reducer\nrecords its identifier and reopens the same turn. The error part remains in\nthe response stream so the failure and recovery decision stay visible in\nhistory.", - "properties": { - "options": { - "type": "array", - "items": { - "$ref": "#/$defs/ErrorRecoveryOption" - }, - "description": "Ordered recovery options supplied by the host." - }, - "selectedOptionId": { - "type": "string", - "description": "Identifier of the option selected by the user, absent until recovery is requested." - } - }, - "required": [ - "options" - ] - }, "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 `recovery` is absent, the error is not recoverable. When it is present\nand `selectedOptionId` is absent, a client may select one of its host-provided\noptions with `chat/errorRecoverySelected`.", + "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 present, 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" }, - "id": { - "type": "string", - "description": "Stable part identifier." - }, "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details." }, - "recovery": { - "$ref": "#/$defs/ErrorRecovery", - "description": "Recovery offered by the host, if any." + "resumable": { + "description": "Whether the host can resume the turn from this error." } }, "required": [ "kind", - "id", "error" ] }, @@ -7293,7 +7241,7 @@ "$ref": "#/$defs/ChatErrorAction" }, { - "$ref": "#/$defs/ChatErrorRecoverySelectedAction" + "$ref": "#/$defs/ChatTurnResumeAction" }, { "$ref": "#/$defs/ChatActivityChangedAction" @@ -8629,7 +8577,7 @@ }, "part": { "$ref": "#/$defs/ErrorResponsePart", - "description": "Error part to append to the response stream before finalizing the turn.\nIts optional recovery options describe the actions the host can perform." + "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", @@ -8644,31 +8592,21 @@ "part" ] }, - "ChatErrorRecoverySelectedAction": { + "ChatTurnResumeAction": { "type": "object", - "description": "A client selected one of the host-provided recovery options on an error.\n\nThe reducer records the selected option identifier on the existing error\nresponse part and reopens the same turn without adding another message. The\nhost performs the opaque recovery behavior identified by `optionId`.", + "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/errorRecoverySelected" + "const": "chat/turnResume" }, "turnId": { "type": "string", "description": "Identifier of the errored turn." - }, - "partId": { - "type": "string", - "description": "Identifier of the error response part." - }, - "optionId": { - "type": "string", - "description": "Identifier of the selected recovery option." } }, "required": [ "type", - "turnId", - "partId", - "optionId" + "turnId" ] }, "ChatActivityChangedAction": { diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 1baa3f8c3..0a982709b 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -3632,76 +3632,24 @@ "request" ] }, - "ErrorRecoveryOption": { - "type": "object", - "description": "An action the host offers to recover from a turn error.\n\nThe `id` is opaque to clients. Selecting an option with\n`chat/errorRecoverySelected` asks the host to perform the corresponding\nrecovery, such as retrying the request or starting a quota-purchase flow.", - "properties": { - "id": { - "type": "string", - "description": "Stable option identifier, returned in `chat/errorRecoverySelected`." - }, - "label": { - "type": "string", - "description": "Human-readable label displayed to the user." - }, - "description": { - "type": "string", - "description": "Optional secondary text." - }, - "recommended": { - "type": "boolean", - "description": "Whether this option is the recommended/default choice." - } - }, - "required": [ - "id", - "label" - ] - }, - "ErrorRecovery": { - "type": "object", - "description": "Recovery offered for an error.\n\nPresence of this object means the host offered recovery. `options` MUST\ncontain at least one entry with a unique `id`. Recovery is available while\n`selectedOptionId` is absent. Once a client selects an option, the reducer\nrecords its identifier and reopens the same turn. The error part remains in\nthe response stream so the failure and recovery decision stay visible in\nhistory.", - "properties": { - "options": { - "type": "array", - "items": { - "$ref": "#/$defs/ErrorRecoveryOption" - }, - "description": "Ordered recovery options supplied by the host." - }, - "selectedOptionId": { - "type": "string", - "description": "Identifier of the option selected by the user, absent until recovery is requested." - } - }, - "required": [ - "options" - ] - }, "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 `recovery` is absent, the error is not recoverable. When it is present\nand `selectedOptionId` is absent, a client may select one of its host-provided\noptions with `chat/errorRecoverySelected`.", + "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 present, 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" }, - "id": { - "type": "string", - "description": "Stable part identifier." - }, "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details." }, - "recovery": { - "$ref": "#/$defs/ErrorRecovery", - "description": "Recovery offered by the host, if any." + "resumable": { + "description": "Whether the host can resume the turn from this error." } }, "required": [ "kind", - "id", "error" ] }, diff --git a/schema/state.schema.json b/schema/state.schema.json index 33e39f8ed..e1a53d91b 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -3380,76 +3380,24 @@ "request" ] }, - "ErrorRecoveryOption": { - "type": "object", - "description": "An action the host offers to recover from a turn error.\n\nThe `id` is opaque to clients. Selecting an option with\n`chat/errorRecoverySelected` asks the host to perform the corresponding\nrecovery, such as retrying the request or starting a quota-purchase flow.", - "properties": { - "id": { - "type": "string", - "description": "Stable option identifier, returned in `chat/errorRecoverySelected`." - }, - "label": { - "type": "string", - "description": "Human-readable label displayed to the user." - }, - "description": { - "type": "string", - "description": "Optional secondary text." - }, - "recommended": { - "type": "boolean", - "description": "Whether this option is the recommended/default choice." - } - }, - "required": [ - "id", - "label" - ] - }, - "ErrorRecovery": { - "type": "object", - "description": "Recovery offered for an error.\n\nPresence of this object means the host offered recovery. `options` MUST\ncontain at least one entry with a unique `id`. Recovery is available while\n`selectedOptionId` is absent. Once a client selects an option, the reducer\nrecords its identifier and reopens the same turn. The error part remains in\nthe response stream so the failure and recovery decision stay visible in\nhistory.", - "properties": { - "options": { - "type": "array", - "items": { - "$ref": "#/$defs/ErrorRecoveryOption" - }, - "description": "Ordered recovery options supplied by the host." - }, - "selectedOptionId": { - "type": "string", - "description": "Identifier of the option selected by the user, absent until recovery is requested." - } - }, - "required": [ - "options" - ] - }, "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 `recovery` is absent, the error is not recoverable. When it is present\nand `selectedOptionId` is absent, a client may select one of its host-provided\noptions with `chat/errorRecoverySelected`.", + "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 present, 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" }, - "id": { - "type": "string", - "description": "Stable part identifier." - }, "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details." }, - "recovery": { - "$ref": "#/$defs/ErrorRecovery", - "description": "Recovery offered by the host, if any." + "resumable": { + "description": "Whether the host can resume the turn from this error." } }, "required": [ "kind", - "id", "error" ] }, diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 71735ba33..cf698be96 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -763,8 +763,6 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'ReasoningResponsePart' }, { name: 'SystemNotificationResponsePart' }, { name: 'InputRequestResponsePart' }, - { name: 'ErrorRecoveryOption' }, - { name: 'ErrorRecovery' }, { name: 'ErrorResponsePart' }, { name: 'ToolCallResult' }, { name: 'ToolCallRiskAssessmentLoadingState' }, @@ -1396,7 +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/errorRecoverySelected', variantName: 'ChatErrorRecoverySelected', tsInterface: 'ChatErrorRecoverySelectedAction' }, + { 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 910f11389..bdd1e22c3 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -931,7 +931,7 @@ const STATE_STRUCTS = [ 'MarkdownResponsePart', 'ContentRef', 'ResourceResponsePart', 'ToolCallResponsePart', 'ReasoningResponsePart', 'SystemNotificationResponsePart', 'InputRequestResponsePart', - 'ErrorRecoveryOption', 'ErrorRecovery', 'ErrorResponsePart', + 'ErrorResponsePart', 'ToolCallResult', 'ToolCallStreamingState', 'ToolCallPendingConfirmationState', 'ToolCallRunningState', 'ToolCallAuthRequiredState', 'ToolCallPendingResultConfirmationState', 'ToolCallCompletedState', @@ -1333,7 +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/errorRecoverySelected', caseName: 'ChatErrorRecoverySelected', tsInterface: 'ChatErrorRecoverySelectedAction' }, + { 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 014bf80f8..4eae61752 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -746,8 +746,6 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'ReasoningResponsePart', omitDiscriminants: true }, { name: 'SystemNotificationResponsePart', omitDiscriminants: true }, { name: 'InputRequestResponsePart', omitDiscriminants: true }, - { name: 'ErrorRecoveryOption' }, - { name: 'ErrorRecovery' }, { name: 'ErrorResponsePart', omitDiscriminants: true }, { name: 'ToolCallResult' }, { name: 'ToolCallRiskAssessmentLoadingState', omitDiscriminants: true }, @@ -1235,7 +1233,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/errorRecoverySelected', variantName: 'ChatErrorRecoverySelected', tsInterface: 'ChatErrorRecoverySelectedAction' }, + { 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-swift.ts b/scripts/generate-swift.ts index 8cdf809af..fab16083f 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -640,7 +640,7 @@ const STATE_STRUCTS = [ 'MarkdownResponsePart', 'ContentRef', 'ResourceResponsePart', 'ToolCallResponsePart', 'ReasoningResponsePart', 'SystemNotificationResponsePart', 'InputRequestResponsePart', - 'ErrorRecoveryOption', 'ErrorRecovery', 'ErrorResponsePart', + 'ErrorResponsePart', 'ToolCallResult', 'ToolCallStreamingState', 'ToolCallPendingConfirmationState', 'ToolCallRunningState', 'ToolCallAuthRequiredState', 'ToolCallPendingResultConfirmationState', 'ToolCallCompletedState', @@ -1227,7 +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/errorRecoverySelected', caseName: 'chatErrorRecoverySelected', tsInterface: 'ChatErrorRecoverySelectedAction' }, + { 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 01c37ca13..5dd37b203 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -49,7 +49,7 @@ import type { ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, - ChatErrorRecoverySelectedAction, + ChatTurnResumeAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, @@ -198,7 +198,7 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction - | ChatErrorRecoverySelectedAction + | ChatTurnResumeAction | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction @@ -223,7 +223,7 @@ export type ClientChatAction = | ChatToolCallResultConfirmedAction | ChatToolCallContentChangedAction | ChatTurnCancelledAction - | ChatErrorRecoverySelectedAction + | ChatTurnResumeAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction | ChatPendingMessageSetAction @@ -406,7 +406,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatTurnComplete]: false, [ActionType.ChatTurnCancelled]: true, [ActionType.ChatError]: false, - [ActionType.ChatErrorRecoverySelected]: true, + [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 f0b54cece..07bfb9468 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -494,7 +494,7 @@ export interface ChatErrorAction { duration: number; /** * Error part to append to the response stream before finalizing the turn. - * Its optional recovery options describe the actions the host can perform. + * Its optional `resumable` flag indicates whether the turn can be resumed. */ part: ErrorResponsePart; /** @@ -510,24 +510,21 @@ export interface ChatErrorAction { } /** - * A client selected one of the host-provided recovery options on an error. + * Resumes the latest errored turn without adding another message. * - * The reducer records the selected option identifier on the existing error - * response part and reopens the same turn without adding another message. The - * host performs the opaque recovery behavior identified by `optionId`. + * 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 ChatErrorRecoverySelectedAction { - type: ActionType.ChatErrorRecoverySelected; +export interface ChatTurnResumeAction { + type: ActionType.ChatTurnResume; /** Identifier of the errored turn. */ turnId: string; - /** Identifier of the error response part. */ - partId: string; - /** Identifier of the selected recovery option. */ - optionId: string; } /** @@ -849,7 +846,7 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction - | ChatErrorRecoverySelectedAction + | ChatTurnResumeAction | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index c66175403..8be4c98c3 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -122,21 +122,9 @@ function findOpenInputRequestPart( return part.kind === ResponsePartKind.InputRequest ? { index, part } : undefined; } -function findAvailableErrorRecoveryPart( - responseParts: readonly ResponsePart[], - partId: string, -): { index: number; part: ErrorResponsePart } | undefined { - const index = responseParts.findIndex(part => - part.kind === ResponsePartKind.Error - && part.id === partId - && part.recovery !== undefined - && part.recovery.selectedOptionId === undefined, - ); - if (index < 0) { - return undefined; - } - const part = responseParts[index]; - return part.kind === ResponsePartKind.Error ? { index, part } : undefined; +function hasResumableError(turn: Turn): boolean { + const part = turn.responseParts[turn.responseParts.length - 1]; + return part?.kind === ResponsePartKind.Error && part.resumable === true; } /** Bitmask covering the mutually-exclusive activity bits (bits 0–4). */ @@ -425,30 +413,15 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st case ActionType.ChatError: return endTurn(state, action.turnId, TurnState.Error, action.duration, SessionStatus.Error, action.part); - case ActionType.ChatErrorRecoverySelected: { + 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) { + if (!turn || turn.id !== action.turnId || turn.state !== TurnState.Error || !hasResumableError(turn)) { return state; } - const recoveryPart = findAvailableErrorRecoveryPart(turn.responseParts, action.partId); - if (!recoveryPart?.part.recovery) { - return state; - } - if (!recoveryPart.part.recovery.options.some(option => option.id === action.optionId)) { - return state; - } - const responseParts = [...turn.responseParts]; - responseParts[recoveryPart.index] = { - ...recoveryPart.part, - recovery: { - ...recoveryPart.part.recovery, - selectedOptionId: action.optionId, - }, - }; const turns = state.turns.slice(); turns.splice(turnIndex, 1); const next: ChatState = { @@ -458,7 +431,7 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st id: turn.id, startedAt: turn.startedAt ?? state.modifiedAt, message: turn.message, - responseParts, + responseParts: turn.responseParts, usage: turn.usage, }, }; diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index d601a83d0..0fbe24c52 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -956,45 +956,6 @@ export interface InputRequestResponsePart { response?: ChatInputResponseKind; } -/** - * An action the host offers to recover from a turn error. - * - * The `id` is opaque to clients. Selecting an option with - * `chat/errorRecoverySelected` asks the host to perform the corresponding - * recovery, such as retrying the request or starting a quota-purchase flow. - * - * @category Response Parts - */ -export interface ErrorRecoveryOption { - /** Stable option identifier, returned in `chat/errorRecoverySelected`. */ - id: string; - /** Human-readable label displayed to the user. */ - label: string; - /** Optional secondary text. */ - description?: string; - /** Whether this option is the recommended/default choice. */ - recommended?: boolean; -} - -/** - * Recovery offered for an error. - * - * Presence of this object means the host offered recovery. `options` MUST - * contain at least one entry with a unique `id`. Recovery is available while - * `selectedOptionId` is absent. Once a client selects an option, the reducer - * records its identifier and reopens the same turn. The error part remains in - * the response stream so the failure and recovery decision stay visible in - * history. - * - * @category Response Parts - */ -export interface ErrorRecovery { - /** Ordered recovery options supplied by the host. */ - options: ErrorRecoveryOption[]; - /** Identifier of the option selected by the user, absent until recovery is requested. */ - selectedOptionId?: string; -} - /** * An error encountered while processing a turn. * @@ -1002,21 +963,19 @@ export interface ErrorRecovery { * remains {@link TurnState.Error} while the turn is stopped at this error so * clients can detect the terminal state without inspecting response parts. * - * When `recovery` is absent, the error is not recoverable. When it is present - * and `selectedOptionId` is absent, a client may select one of its host-provided - * options with `chat/errorRecoverySelected`. + * When {@link resumable} is present, 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; - /** Stable part identifier. */ - id: string; /** Error details. */ error: ErrorInfo; - /** Recovery offered by the host, if any. */ - recovery?: ErrorRecovery; + /** Whether the host can resume the turn from this error. */ + resumable?: true; } /** diff --git a/types/common/actions.ts b/types/common/actions.ts index b95fd291b..708b94463 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -61,7 +61,7 @@ import type { ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, - ChatErrorRecoverySelectedAction, + ChatTurnResumeAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, @@ -146,7 +146,7 @@ export const enum ActionType { ChatTurnComplete = 'chat/turnComplete', ChatTurnCancelled = 'chat/turnCancelled', ChatError = 'chat/error', - ChatErrorRecoverySelected = 'chat/errorRecoverySelected', + ChatTurnResume = 'chat/turnResume', ChatActivityChanged = 'chat/activityChanged', ChatWorkingDirectorySet = 'chat/workingDirectorySet', ChatWorkingDirectoryRemoved = 'chat/workingDirectoryRemoved', @@ -291,7 +291,7 @@ export type StateAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction - | ChatErrorRecoverySelectedAction + | ChatTurnResumeAction | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction 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 74e1b6936..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 @@ -27,7 +27,6 @@ "duration": 8999, "part": { "kind": "error", - "id": "error-1", "error": { "errorType": "runtime", "message": "Something broke" @@ -50,7 +49,6 @@ "responseParts": [ { "kind": "error", - "id": "error-1", "error": { "errorType": "runtime", "message": "Something broke" diff --git a/types/test-cases/reducers/263-chat-error-recovery-reopens-turn.json b/types/test-cases/reducers/263-chat-turn-resume-reopens-turn.json similarity index 58% rename from types/test-cases/reducers/263-chat-error-recovery-reopens-turn.json rename to types/test-cases/reducers/263-chat-turn-resume-reopens-turn.json index c96f50992..46390ac73 100644 --- a/types/test-cases/reducers/263-chat-error-recovery-reopens-turn.json +++ b/types/test-cases/reducers/263-chat-turn-resume-reopens-turn.json @@ -1,5 +1,5 @@ { - "description": "chat/errorRecoverySelected records the selected host option and reopens the same turn", + "description": "chat/turnResume reopens the latest turn after a resumable error", "reducer": "chat", "initial": { "turns": [ @@ -21,25 +21,11 @@ }, { "kind": "error", - "id": "error-1", "error": { "errorType": "quota", "message": "More tokens are required" }, - "recovery": { - "options": [ - { - "id": "retry", - "label": "Try Again" - }, - { - "id": "buy-tokens", - "label": "Buy More Tokens", - "description": "Increase the available quota", - "recommended": true - } - ] - } + "resumable": true } ], "usage": null, @@ -53,10 +39,8 @@ }, "actions": [ { - "type": "chat/errorRecoverySelected", - "turnId": "turn-1", - "partId": "error-1", - "optionId": "buy-tokens" + "type": "chat/turnResume", + "turnId": "turn-1" } ], "expected": { @@ -78,26 +62,11 @@ }, { "kind": "error", - "id": "error-1", "error": { "errorType": "quota", "message": "More tokens are required" }, - "recovery": { - "options": [ - { - "id": "retry", - "label": "Try Again" - }, - { - "id": "buy-tokens", - "label": "Buy More Tokens", - "description": "Increase the available quota", - "recommended": true - } - ], - "selectedOptionId": "buy-tokens" - } + "resumable": true } ], "usage": null diff --git a/types/test-cases/reducers/264-chat-error-recovery-noop-with-active-turn.json b/types/test-cases/reducers/264-chat-turn-resume-noop-with-active-turn.json similarity index 81% rename from types/test-cases/reducers/264-chat-error-recovery-noop-with-active-turn.json rename to types/test-cases/reducers/264-chat-turn-resume-noop-with-active-turn.json index 33299efaa..04501fd21 100644 --- a/types/test-cases/reducers/264-chat-error-recovery-noop-with-active-turn.json +++ b/types/test-cases/reducers/264-chat-turn-resume-noop-with-active-turn.json @@ -1,5 +1,5 @@ { - "description": "chat/errorRecoverySelected is a no-op while a turn is active", + "description": "chat/turnResume does nothing while a turn is active", "reducer": "chat", "initial": { "turns": [], @@ -22,10 +22,8 @@ }, "actions": [ { - "type": "chat/errorRecoverySelected", - "turnId": "turn-1", - "partId": "error-1", - "optionId": "retry" + "type": "chat/turnResume", + "turnId": "turn-1" } ], "expected": { diff --git a/types/test-cases/reducers/265-chat-error-recovery-noop-for-unknown-turn.json b/types/test-cases/reducers/265-chat-turn-resume-noop-for-unknown-turn.json similarity index 65% rename from types/test-cases/reducers/265-chat-error-recovery-noop-for-unknown-turn.json rename to types/test-cases/reducers/265-chat-turn-resume-noop-for-unknown-turn.json index d5cc6d9e8..380eef696 100644 --- a/types/test-cases/reducers/265-chat-error-recovery-noop-for-unknown-turn.json +++ b/types/test-cases/reducers/265-chat-turn-resume-noop-for-unknown-turn.json @@ -1,5 +1,5 @@ { - "description": "chat/errorRecoverySelected is a no-op when the turn is unknown", + "description": "chat/turnResume does nothing when the turn is unknown", "reducer": "chat", "initial": { "turns": [], @@ -10,10 +10,8 @@ }, "actions": [ { - "type": "chat/errorRecoverySelected", - "turnId": "turn-1", - "partId": "error-1", - "optionId": "retry" + "type": "chat/turnResume", + "turnId": "turn-1" } ], "expected": { diff --git a/types/test-cases/reducers/266-chat-error-recovery-noop-for-nonlatest-turn.json b/types/test-cases/reducers/266-chat-turn-resume-noop-for-nonlatest-turn.json similarity index 72% rename from types/test-cases/reducers/266-chat-error-recovery-noop-for-nonlatest-turn.json rename to types/test-cases/reducers/266-chat-turn-resume-noop-for-nonlatest-turn.json index 3cfa66256..888dbb598 100644 --- a/types/test-cases/reducers/266-chat-error-recovery-noop-for-nonlatest-turn.json +++ b/types/test-cases/reducers/266-chat-turn-resume-noop-for-nonlatest-turn.json @@ -1,5 +1,5 @@ { - "description": "chat/errorRecoverySelected is a no-op when the errored turn is not latest", + "description": "chat/turnResume does nothing when the errored turn is not latest", "reducer": "chat", "initial": { "turns": [ @@ -14,19 +14,11 @@ "responseParts": [ { "kind": "error", - "id": "error-1", "error": { "errorType": "runtime", "message": "Something broke" }, - "recovery": { - "options": [ - { - "id": "retry", - "label": "Try Again" - } - ] - } + "resumable": true } ], "usage": null, @@ -52,10 +44,8 @@ }, "actions": [ { - "type": "chat/errorRecoverySelected", - "turnId": "turn-1", - "partId": "error-1", - "optionId": "retry" + "type": "chat/turnResume", + "turnId": "turn-1" } ], "expected": { @@ -71,19 +61,11 @@ "responseParts": [ { "kind": "error", - "id": "error-1", "error": { "errorType": "runtime", "message": "Something broke" }, - "recovery": { - "options": [ - { - "id": "retry", - "label": "Try Again" - } - ] - } + "resumable": true } ], "usage": null, diff --git a/types/test-cases/reducers/267-chat-error-recovery-noop-without-available-option.json b/types/test-cases/reducers/267-chat-error-recovery-noop-without-available-option.json deleted file mode 100644 index 269acbcaf..000000000 --- a/types/test-cases/reducers/267-chat-error-recovery-noop-without-available-option.json +++ /dev/null @@ -1,154 +0,0 @@ -{ - "description": "chat/errorRecoverySelected is a no-op for unavailable, already selected, and unknown options", - "reducer": "chat", - "initial": { - "turns": [ - { - "id": "turn-1", - "message": { - "text": "Hello", - "origin": { - "kind": "user" - } - }, - "responseParts": [ - { - "kind": "error", - "id": "unrecoverable", - "error": { - "errorType": "fatal", - "message": "Cannot recover" - } - }, - { - "kind": "error", - "id": "selected", - "error": { - "errorType": "quota", - "message": "Quota exceeded" - }, - "recovery": { - "options": [ - { - "id": "buy", - "label": "Buy More Tokens" - } - ], - "selectedOptionId": "buy" - } - }, - { - "kind": "error", - "id": "available", - "error": { - "errorType": "runtime", - "message": "Try again" - }, - "recovery": { - "options": [ - { - "id": "retry", - "label": "Try Again" - } - ] - } - } - ], - "usage": null, - "state": "error" - } - ], - "resource": "copilot:/test-session", - "title": "Test Session", - "status": 2, - "modifiedAt": "1970-01-01T00:00:02.000Z" - }, - "actions": [ - { - "type": "chat/errorRecoverySelected", - "turnId": "turn-1", - "partId": "missing", - "optionId": "retry" - }, - { - "type": "chat/errorRecoverySelected", - "turnId": "turn-1", - "partId": "unrecoverable", - "optionId": "retry" - }, - { - "type": "chat/errorRecoverySelected", - "turnId": "turn-1", - "partId": "selected", - "optionId": "buy" - }, - { - "type": "chat/errorRecoverySelected", - "turnId": "turn-1", - "partId": "available", - "optionId": "unknown" - } - ], - "expected": { - "turns": [ - { - "id": "turn-1", - "message": { - "text": "Hello", - "origin": { - "kind": "user" - } - }, - "responseParts": [ - { - "kind": "error", - "id": "unrecoverable", - "error": { - "errorType": "fatal", - "message": "Cannot recover" - } - }, - { - "kind": "error", - "id": "selected", - "error": { - "errorType": "quota", - "message": "Quota exceeded" - }, - "recovery": { - "options": [ - { - "id": "buy", - "label": "Buy More Tokens" - } - ], - "selectedOptionId": "buy" - } - }, - { - "kind": "error", - "id": "available", - "error": { - "errorType": "runtime", - "message": "Try again" - }, - "recovery": { - "options": [ - { - "id": "retry", - "label": "Try Again" - } - ] - } - } - ], - "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/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-error-recovery-preserves-errors-across-retries.json b/types/test-cases/reducers/268-chat-turn-resume-preserves-errors-across-retries.json similarity index 71% rename from types/test-cases/reducers/268-chat-error-recovery-preserves-errors-across-retries.json rename to types/test-cases/reducers/268-chat-turn-resume-preserves-errors-across-retries.json index 3ba8bc839..49b6addda 100644 --- a/types/test-cases/reducers/268-chat-error-recovery-preserves-errors-across-retries.json +++ b/types/test-cases/reducers/268-chat-turn-resume-preserves-errors-across-retries.json @@ -1,5 +1,5 @@ { - "description": "a recovered turn keeps its prior error and selection when it errors again", + "description": "a resumed turn preserves its prior error when it errors again", "reducer": "chat", "initial": { "turns": [], @@ -27,26 +27,16 @@ "duration": 1000, "part": { "kind": "error", - "id": "error-1", "error": { "errorType": "runtime", "message": "First failure" }, - "recovery": { - "options": [ - { - "id": "retry", - "label": "Try Again" - } - ] - } + "resumable": true } }, { - "type": "chat/errorRecoverySelected", - "turnId": "turn-1", - "partId": "error-1", - "optionId": "retry" + "type": "chat/turnResume", + "turnId": "turn-1" }, { "type": "chat/error", @@ -54,7 +44,6 @@ "duration": 2000, "part": { "kind": "error", - "id": "error-2", "error": { "errorType": "fatal", "message": "Second failure" @@ -77,24 +66,14 @@ "responseParts": [ { "kind": "error", - "id": "error-1", "error": { "errorType": "runtime", "message": "First failure" }, - "recovery": { - "options": [ - { - "id": "retry", - "label": "Try Again" - } - ], - "selectedOptionId": "retry" - } + "resumable": true }, { "kind": "error", - "id": "error-2", "error": { "errorType": "fatal", "message": "Second failure" diff --git a/types/test-cases/reducers/269-chat-error-recovery-completes-one-turn.json b/types/test-cases/reducers/269-chat-turn-resume-completes-one-turn.json similarity index 67% rename from types/test-cases/reducers/269-chat-error-recovery-completes-one-turn.json rename to types/test-cases/reducers/269-chat-turn-resume-completes-one-turn.json index 1bbbaac7f..8e4f0b8df 100644 --- a/types/test-cases/reducers/269-chat-error-recovery-completes-one-turn.json +++ b/types/test-cases/reducers/269-chat-turn-resume-completes-one-turn.json @@ -1,5 +1,5 @@ { - "description": "a recovered turn completes as one turn while preserving its error and recovery decision", + "description": "a resumed turn completes as one turn and preserves its error", "reducer": "chat", "initial": { "turns": [ @@ -16,19 +16,11 @@ "responseParts": [ { "kind": "error", - "id": "error-1", "error": { "errorType": "runtime", "message": "Something broke" }, - "recovery": { - "options": [ - { - "id": "retry", - "label": "Try Again" - } - ] - } + "resumable": true } ], "usage": null, @@ -42,10 +34,8 @@ }, "actions": [ { - "type": "chat/errorRecoverySelected", - "turnId": "turn-1", - "partId": "error-1", - "optionId": "retry" + "type": "chat/turnResume", + "turnId": "turn-1" }, { "type": "chat/turnComplete", @@ -68,20 +58,11 @@ "responseParts": [ { "kind": "error", - "id": "error-1", "error": { "errorType": "runtime", "message": "Something broke" }, - "recovery": { - "options": [ - { - "id": "retry", - "label": "Try Again" - } - ], - "selectedOptionId": "retry" - } + "resumable": true } ], "usage": null, 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 index a9184ee40..10d176f88 100644 --- a/types/test-cases/reducers/270-chat-responsepart-cannot-append-error.json +++ b/types/test-cases/reducers/270-chat-responsepart-cannot-append-error.json @@ -26,7 +26,6 @@ "turnId": "turn-1", "part": { "kind": "error", - "id": "error-1", "error": { "errorType": "runtime", "message": "Something broke" diff --git a/types/version/registry.ts b/types/version/registry.ts index 86f1430a6..5d5727a20 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -123,7 +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.ChatErrorRecoverySelected]: '0.8.0', + [ActionType.ChatTurnResume]: '0.8.0', [ActionType.ChatActivityChanged]: '0.5.0', [ActionType.ChatWorkingDirectorySet]: '0.7.0', [ActionType.ChatWorkingDirectoryRemoved]: '0.7.0', From a1e3a492786eac5fc9c1862ab7382e86e3a56bc4 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 11 Aug 2026 15:47:53 -0700 Subject: [PATCH 3/5] swift: handle id-less error response parts Return no response-part identifier for durable error parts, which do not carry an id.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Sources/AgentHostProtocol/ToolCallStateExtensions.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift index 23b5d4a1e..e76cee02a 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift @@ -98,7 +98,7 @@ extension ResponsePart { case .contentRef: return nil case .systemNotification: return nil case .inputRequest: return nil - case .error(let error): return error.id + case .error: return nil case .unknown: return nil } } From deaa49467213b77c5542d9df4a1efaec32456f47 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 11 Aug 2026 19:09:23 -0700 Subject: [PATCH 4/5] chat: address resumable error review Tighten generic response parts to exclude errors, align resumability across generated clients, preserve the Rust error discriminator, update the Swift example, and cover completed-turn rejection.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahp/reducers.go | 33 ++++++- clients/go/ahptypes/actions.generated.go | 4 +- clients/go/ahptypes/state.generated.go | 92 ++++++++++++++++++- clients/go/examples/reducers_demo/main.go | 2 +- .../microsoft/agenthostprotocol/Reducers.kt | 19 +++- .../generated/Actions.generated.kt | 4 +- .../generated/State.generated.kt | 67 +++++++++++++- clients/rust/crates/ahp-types/src/actions.rs | 45 ++++++++- clients/rust/crates/ahp-types/src/state.rs | 26 +++++- clients/rust/crates/ahp/src/reducers.rs | 38 ++++++-- .../swift/AHPApp/AHPApp/Views/ChatView.swift | 10 -- .../AHPApp/Views/ResponsePartView.swift | 19 ++++ .../Generated/Actions.generated.swift | 6 +- .../Generated/State.generated.swift | 51 +++++++++- .../Sources/AgentHostProtocol/Reducers.swift | 24 ++++- docs/guide/state-model.md | 2 +- docs/specification/chat-channel.md | 4 +- schema/actions.schema.json | 22 +++-- schema/commands.schema.json | 52 ++++++----- schema/errors.schema.json | 52 ++++++----- schema/notifications.schema.json | 48 ++++++---- schema/state.schema.json | 18 +++- scripts/generate-go.ts | 19 +++- scripts/generate-kotlin.ts | 16 +++- scripts/generate-rust.ts | 73 ++++++++++++++- scripts/generate-swift.ts | 24 +++-- types/channels-chat/actions.ts | 6 +- types/channels-chat/reducer.ts | 6 +- types/channels-chat/state.ts | 24 +++-- ...9-chat-turn-resume-completes-one-turn.json | 4 + .../041-chat-error-part-discriminator.json | 34 +++++++ 31 files changed, 702 insertions(+), 142 deletions(-) create mode 100644 types/test-cases/round-trips/041-chat-error-part-discriminator.json diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index b22cfefc2..cc8f43d5b 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -452,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 @@ -471,6 +472,33 @@ func updateResponsePart(state *ahptypes.ChatState, turnID, partID string, update return ReduceOutcomeNoOp } +func responsePartFromAppendable(part ahptypes.AppendableResponsePart) (ahptypes.ResponsePart, bool) { + switch value := part.Value.(type) { + case *ahptypes.MarkdownResponsePart: + return ahptypes.ResponsePart{Value: value}, true + case *ahptypes.ResourceResponsePart: + return ahptypes.ResponsePart{Value: value}, true + case *ahptypes.ToolCallResponsePart: + return ahptypes.ResponsePart{Value: value}, true + case *ahptypes.ReasoningResponsePart: + return ahptypes.ResponsePart{Value: value}, true + case *ahptypes.SystemNotificationResponsePart: + return ahptypes.ResponsePart{Value: value}, true + case *ahptypes.InputRequestResponsePart: + return ahptypes.ResponsePart{Value: value}, true + case *ahptypes.AppendableResponsePartUnknown: + var discriminator struct { + Kind string `json:"kind"` + } + if json.Unmarshal(value.Raw, &discriminator) != nil || discriminator.Kind == "error" { + return ahptypes.ResponsePart{}, false + } + return ahptypes.ResponsePart{Value: &ahptypes.ResponsePartUnknown{Raw: value.Raw}}, true + default: + return ahptypes.ResponsePart{}, false + } +} + // ─── Root Reducer ────────────────────────────────────────────────────── // ApplyActionToRoot applies action to the [ahptypes.RootState] in @@ -525,10 +553,11 @@ 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 { + part, ok := responsePartFromAppendable(a.Part) + if !ok { return ReduceOutcomeNoOp } - state.ActiveTurn.ResponseParts = append(state.ActiveTurn.ResponseParts, a.Part) + state.ActiveTurn.ResponseParts = append(state.ActiveTurn.ResponseParts, part) return ReduceOutcomeApplied case *ahptypes.ChatTurnCompleteAction: return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateComplete, nil, nil) diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index 288e742d8..fc92b8eae 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -265,8 +265,8 @@ type ChatResponsePartAction struct { Type ActionType `json:"type"` // Turn identifier TurnId string `json:"turnId"` - // Response part to append; error parts are ignored. - Part ResponsePart `json:"part"` + // Non-error response part to append. + Part AppendableResponsePart `json:"part"` // Additional provider-specific metadata for this action. // // Clients MAY look for well-known keys here to provide enhanced UI, and diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index b3143b2be..b386881ba 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -1863,7 +1863,7 @@ type InputRequestResponsePart struct { // 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 present, a client may dispatch `chat/turnResume` +// 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 { @@ -1871,7 +1871,7 @@ type ErrorResponsePart struct { Kind ResponsePartKind `json:"kind"` // Error details. Error ErrorInfo `json:"error"` - // Whether the host can resume the turn from this error. + // Whether the host can resume the turn from this error. Only `true` enables resume. Resumable *bool `json:"resumable,omitempty"` } @@ -3607,6 +3607,94 @@ func (t *ToolInput) UnmarshalJSON(data []byte) error { // ─── Discriminated Unions ───────────────────────────────────────────── +// AppendableResponsePart is a non-error part that may be appended while a turn is active. +type AppendableResponsePart struct { + Value isAppendableResponsePart +} + +// isAppendableResponsePart is the marker interface implemented by every +// concrete variant of AppendableResponsePart. +type isAppendableResponsePart interface{ isAppendableResponsePart() } + +func (*MarkdownResponsePart) isAppendableResponsePart() {} +func (*ResourceResponsePart) isAppendableResponsePart() {} +func (*ToolCallResponsePart) isAppendableResponsePart() {} +func (*ReasoningResponsePart) isAppendableResponsePart() {} +func (*SystemNotificationResponsePart) isAppendableResponsePart() {} +func (*InputRequestResponsePart) isAppendableResponsePart() {} + +// AppendableResponsePartUnknown carries an unrecognized AppendableResponsePart 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 AppendableResponsePartUnknown struct { + Raw json.RawMessage +} + +func (*AppendableResponsePartUnknown) isAppendableResponsePart() {} + +// UnmarshalJSON decodes the variant indicated by the "kind" discriminator. +func (u *AppendableResponsePart) UnmarshalJSON(data []byte) error { + disc, _, err := readDiscriminator(data, "kind") + if err != nil { + return err + } + switch disc { + case "markdown": + var value MarkdownResponsePart + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "contentRef": + var value ResourceResponsePart + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "toolCall": + var value ToolCallResponsePart + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "reasoning": + var value ReasoningResponsePart + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "systemNotification": + var value SystemNotificationResponsePart + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "inputRequest": + var value InputRequestResponsePart + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + default: + raw := make(json.RawMessage, len(data)) + copy(raw, data) + u.Value = &AppendableResponsePartUnknown{Raw: raw} + } + return nil +} + +// MarshalJSON encodes the active variant back to JSON. +func (u AppendableResponsePart) MarshalJSON() ([]byte, error) { + if unk, ok := u.Value.(*AppendableResponsePartUnknown); ok { + if len(unk.Raw) == 0 { + return []byte("null"), nil + } + return unk.Raw, nil + } + if u.Value == nil { + return []byte("null"), nil + } + return json.Marshal(u.Value) +} + // ResponsePart is a single part of a response stream (text, tool call, reasoning, content reference). type ResponsePart struct { Value isResponsePart diff --git a/clients/go/examples/reducers_demo/main.go b/clients/go/examples/reducers_demo/main.go index 139d8c637..d9818a6c7 100644 --- a/clients/go/examples/reducers_demo/main.go +++ b/clients/go/examples/reducers_demo/main.go @@ -28,7 +28,7 @@ func main() { {Value: &ahptypes.ChatResponsePartAction{ Type: ahptypes.ActionTypeChatResponsePart, TurnId: "t1", - Part: ahptypes.ResponsePart{Value: &ahptypes.MarkdownResponsePart{ + Part: ahptypes.AppendableResponsePart{Value: &ahptypes.MarkdownResponsePart{ Kind: ahptypes.ResponsePartKindMarkdown, Id: "p1", Content: "Hi ", 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 863ff16d1..9e0ace5e9 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -11,6 +11,8 @@ package com.microsoft.agenthostprotocol import com.microsoft.agenthostprotocol.generated.* import java.time.Instant import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull // ─── Reducer Interface ────────────────────────────────────────────────────── @@ -28,6 +30,19 @@ public fun interface Reducer { public fun reduce(state: S, action: A): S } +private fun AppendableResponsePart.toResponsePart(): ResponsePart = when (this) { + is AppendableResponsePartMarkdown -> ResponsePartMarkdown(value) + is AppendableResponsePartContentRef -> ResponsePartContentRef(value) + is AppendableResponsePartToolCall -> ResponsePartToolCall(value) + is AppendableResponsePartReasoning -> ResponsePartReasoning(value) + is AppendableResponsePartSystemNotification -> ResponsePartSystemNotification(value) + is AppendableResponsePartInputRequest -> ResponsePartInputRequest(value) + is AppendableResponsePartUnknown -> ResponsePartUnknown(raw) +} + +private fun AppendableResponsePart.isErrorResponsePart(): Boolean = + this is AppendableResponsePartUnknown && (raw["kind"] as? JsonPrimitive)?.contentOrNull == "error" + /** Pure root reducer as a [Reducer] instance. Delegates to [rootReducer]. */ public object RootReducer : Reducer { override fun reduce(state: RootState, action: StateAction): RootState = @@ -867,11 +882,11 @@ 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 || a.part is ResponsePartError) { + if (activeTurn == null || activeTurn.id != a.turnId || a.part.isErrorResponsePart()) { state } else { state.copy( - activeTurn = activeTurn.copy(responseParts = activeTurn.responseParts + a.part), + activeTurn = activeTurn.copy(responseParts = activeTurn.responseParts + a.part.toResponsePart()), ) } } 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 d20a379b4..17e3911b5 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 @@ -365,9 +365,9 @@ data class ChatResponsePartAction( */ val turnId: String, /** - * Response part to append; error parts are ignored. + * Non-error response part to append. */ - val part: ResponsePart, + val part: AppendableResponsePart, /** * Additional provider-specific metadata for this action. * 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 23564fbb9..810aaab67 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 @@ -2520,7 +2520,7 @@ data class ErrorResponsePart( */ val error: ErrorInfo, /** - * Whether the host can resume the turn from this error. + * Whether the host can resume the turn from this error. Only `true` enables resume. */ val resumable: Boolean? = null ) @@ -4784,6 +4784,71 @@ internal object ChatOriginSerializer : KSerializer { } } +@Serializable(with = AppendableResponsePartSerializer::class) +sealed interface AppendableResponsePart + +@JvmInline +value class AppendableResponsePartMarkdown(val value: MarkdownResponsePart) : AppendableResponsePart +@JvmInline +value class AppendableResponsePartContentRef(val value: ResourceResponsePart) : AppendableResponsePart +@JvmInline +value class AppendableResponsePartToolCall(val value: ToolCallResponsePart) : AppendableResponsePart +@JvmInline +value class AppendableResponsePartReasoning(val value: ReasoningResponsePart) : AppendableResponsePart +@JvmInline +value class AppendableResponsePartSystemNotification(val value: SystemNotificationResponsePart) : AppendableResponsePart +@JvmInline +value class AppendableResponsePartInputRequest(val value: InputRequestResponsePart) : AppendableResponsePart +/** + * Forward-compat catch-all for unknown AppendableResponsePart discriminators. + * + * Older clients may receive newer wire variants they don't recognise; capturing + * the raw `JsonObject` lets such payloads round-trip through the client unchanged. + * Reducers handle this variant conservatively on a per-union basis (typically + * as a no-op, but see `Reducers.kt` for the exact treatment). + */ +@JvmInline +value class AppendableResponsePartUnknown(val raw: JsonObject) : AppendableResponsePart + +internal object AppendableResponsePartSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("AppendableResponsePart") + + override fun deserialize(decoder: Decoder): AppendableResponsePart { + val input = decoder as? JsonDecoder + ?: error("AppendableResponsePart can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for AppendableResponsePart") + val discriminant = (obj["kind"] as? JsonPrimitive)?.content + ?: return AppendableResponsePartUnknown(obj) + return when (discriminant) { + "markdown" -> AppendableResponsePartMarkdown(input.json.decodeFromJsonElement(MarkdownResponsePart.serializer(), element)) + "contentRef" -> AppendableResponsePartContentRef(input.json.decodeFromJsonElement(ResourceResponsePart.serializer(), element)) + "toolCall" -> AppendableResponsePartToolCall(input.json.decodeFromJsonElement(ToolCallResponsePart.serializer(), element)) + "reasoning" -> AppendableResponsePartReasoning(input.json.decodeFromJsonElement(ReasoningResponsePart.serializer(), element)) + "systemNotification" -> AppendableResponsePartSystemNotification(input.json.decodeFromJsonElement(SystemNotificationResponsePart.serializer(), element)) + "inputRequest" -> AppendableResponsePartInputRequest(input.json.decodeFromJsonElement(InputRequestResponsePart.serializer(), element)) + else -> AppendableResponsePartUnknown(obj) + } + } + + override fun serialize(encoder: Encoder, value: AppendableResponsePart) { + val output = encoder as? JsonEncoder + ?: error("AppendableResponsePart can only be serialized to JSON") + val element: JsonElement = when (value) { + is AppendableResponsePartMarkdown -> output.json.encodeToJsonElement(MarkdownResponsePart.serializer(), value.value) + is AppendableResponsePartContentRef -> output.json.encodeToJsonElement(ResourceResponsePart.serializer(), value.value) + is AppendableResponsePartToolCall -> output.json.encodeToJsonElement(ToolCallResponsePart.serializer(), value.value) + is AppendableResponsePartReasoning -> output.json.encodeToJsonElement(ReasoningResponsePart.serializer(), value.value) + is AppendableResponsePartSystemNotification -> output.json.encodeToJsonElement(SystemNotificationResponsePart.serializer(), value.value) + is AppendableResponsePartInputRequest -> output.json.encodeToJsonElement(InputRequestResponsePart.serializer(), value.value) + is AppendableResponsePartUnknown -> value.raw + } + output.encodeJsonElement(element) + } +} + @Serializable(with = ResponsePartSerializer::class) sealed interface ResponsePart diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index f1f16a8b5..0db1a22f1 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -13,8 +13,8 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; #[allow(unused_imports)] use crate::state::{ - AgentInfo, AgentSelection, Annotation, AnnotationEntry, Changeset, ChangesetFile, - ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, + AgentInfo, AgentSelection, Annotation, AnnotationEntry, AppendableResponsePart, Changeset, + ChangesetFile, ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ChatSummary, ConfirmationOption, ContentRef, Customization, ErrorInfo, ErrorResponsePart, McpAuthRequirement, McpServerState, Message, ModelSelection, PendingMessageKind, ResponsePart, @@ -388,8 +388,8 @@ pub struct ChatDeltaAction { pub struct ChatResponsePartAction { /// Turn identifier pub turn_id: String, - /// Response part to append; error parts are ignored. - pub part: ResponsePart, + /// Non-error response part to append. + pub part: AppendableResponsePart, /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -757,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 @@ -781,6 +781,41 @@ 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 diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index ecbe95ab7..13c880a50 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -2290,7 +2290,7 @@ pub struct InputRequestResponsePart { /// 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 present, a client may dispatch `chat/turnResume` +/// 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)] @@ -2298,7 +2298,7 @@ pub struct InputRequestResponsePart { pub struct ErrorResponsePart { /// Error details. pub error: ErrorInfo, - /// Whether the host can resume the turn from this error. + /// 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, } @@ -4329,6 +4329,28 @@ pub enum ChatOrigin { Unknown(serde_json::Value), } +/// A non-error part that may be appended while a turn is active. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum AppendableResponsePart { + #[serde(rename = "markdown")] + Markdown(MarkdownResponsePart), + #[serde(rename = "contentRef")] + ContentRef(ResourceResponsePart), + #[serde(rename = "toolCall")] + ToolCall(Box), + #[serde(rename = "reasoning")] + Reasoning(ReasoningResponsePart), + #[serde(rename = "systemNotification")] + SystemNotification(SystemNotificationResponsePart), + #[serde(rename = "inputRequest")] + InputRequest(InputRequestResponsePart), + /// Unknown or future variant — preserved as raw JSON for round-trip fidelity. + /// Reducers treat this as a no-op. + #[serde(untagged)] + Unknown(serde_json::Value), +} + /// A single part of a response stream (text, tool call, reasoning, content reference). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind")] diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 7ea11906e..469fbaef5 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -58,11 +58,11 @@ use ahp_types::actions::{ ChatTurnStartedAction, StateAction, }; use ahp_types::state::{ - ActiveTurn, AnnotationsState, ChangesetOperationStatus, ChangesetState, ChangesetStatus, - ChatInputRequest, ChatState, ChildCustomization, ConfirmationOption, Customization, - ErrorResponsePart, InputRequestResponsePart, McpServerStartingState, McpServerState, - McpServerStoppedState, PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, - RootState, SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, + ActiveTurn, AnnotationsState, AppendableResponsePart, ChangesetOperationStatus, ChangesetState, + ChangesetStatus, 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, @@ -613,6 +613,28 @@ where ReduceOutcome::NoOp } +fn appendable_response_part(part: &AppendableResponsePart) -> ResponsePart { + match part { + AppendableResponsePart::Markdown(value) => ResponsePart::Markdown(value.clone()), + AppendableResponsePart::ContentRef(value) => ResponsePart::ContentRef(value.clone()), + AppendableResponsePart::ToolCall(value) => ResponsePart::ToolCall(value.clone()), + AppendableResponsePart::Reasoning(value) => ResponsePart::Reasoning(value.clone()), + AppendableResponsePart::SystemNotification(value) => { + ResponsePart::SystemNotification(value.clone()) + } + AppendableResponsePart::InputRequest(value) => ResponsePart::InputRequest(value.clone()), + AppendableResponsePart::Unknown(value) => ResponsePart::Unknown(value.clone()), + } +} + +fn is_error_response_part(part: &AppendableResponsePart) -> bool { + matches!( + part, + AppendableResponsePart::Unknown(serde_json::Value::Object(value)) + if value.get("kind").and_then(serde_json::Value::as_str) == Some("error") + ) +} + // ─── Root Reducer ───────────────────────────────────────────────────── /// Apply a [`StateAction`] to a [`RootState`] in place. @@ -971,10 +993,12 @@ 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(_)) { + if is_error_response_part(&a.part) { return ReduceOutcome::NoOp; } - active.response_parts.push(a.part.clone()); + active + .response_parts + .push(appendable_response_part(&a.part)); ReduceOutcome::Applied } StateAction::ChatTurnComplete(a) => end_turn( 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 356f0ae24..1623a14d7 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -338,8 +338,8 @@ public struct ChatResponsePartAction: Codable, Sendable { public var type: ActionType /// Turn identifier public var turnId: String - /// Response part to append; error parts are ignored. - public var part: ResponsePart + /// Non-error response part to append. + public var part: AppendableResponsePart /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -359,7 +359,7 @@ public struct ChatResponsePartAction: Codable, Sendable { public init( type: ActionType, turnId: String, - part: ResponsePart, + part: AppendableResponsePart, meta: [String: AnyCodable]? = nil ) { self.type = type diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 938b69473..2e24c5de6 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -2532,7 +2532,7 @@ public struct ErrorResponsePart: Codable, Sendable { public var kind: ResponsePartKind /// Error details. public var error: ErrorInfo - /// Whether the host can resume the turn from this error. + /// Whether the host can resume the turn from this error. Only `true` enables resume. public var resumable: Bool? public init( @@ -5347,6 +5347,55 @@ public enum ChatOrigin: Codable, Sendable { } } +public enum AppendableResponsePart: Codable, Sendable { + case markdown(MarkdownResponsePart) + case contentRef(ResourceResponsePart) + case toolCall(ToolCallResponsePart) + case reasoning(ReasoningResponsePart) + case systemNotification(SystemNotificationResponsePart) + case inputRequest(InputRequestResponsePart) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) + + private enum DiscriminantKey: String, CodingKey { + case discriminant = "kind" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + let discriminant = try container.decode(String.self, forKey: .discriminant) + switch discriminant { + case "markdown": + self = .markdown(try MarkdownResponsePart(from: decoder)) + case "contentRef": + self = .contentRef(try ResourceResponsePart(from: decoder)) + case "toolCall": + self = .toolCall(try ToolCallResponsePart(from: decoder)) + case "reasoning": + self = .reasoning(try ReasoningResponsePart(from: decoder)) + case "systemNotification": + self = .systemNotification(try SystemNotificationResponsePart(from: decoder)) + case "inputRequest": + self = .inputRequest(try InputRequestResponsePart(from: decoder)) + default: + self = .unknown(try AnyCodable(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .markdown(let value): try value.encode(to: encoder) + case .contentRef(let value): try value.encode(to: encoder) + case .toolCall(let value): try value.encode(to: encoder) + 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 .unknown(let value): try value.encode(to: encoder) + } + } +} + public enum ResponsePart: Codable, Sendable { case markdown(MarkdownResponsePart) case contentRef(ResourceResponsePart) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 617d4bf66..331f78436 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -27,6 +27,26 @@ private func withStatusFlag(_ status: SessionStatus, _ flag: SessionStatus, _ se set ? status.union(flag) : status.subtracting(flag) } +private func responsePart(_ part: AppendableResponsePart) -> ResponsePart { + switch part { + case .markdown(let value): return .markdown(value) + case .contentRef(let value): return .contentRef(value) + case .toolCall(let value): return .toolCall(value) + case .reasoning(let value): return .reasoning(value) + case .systemNotification(let value): return .systemNotification(value) + case .inputRequest(let value): return .inputRequest(value) + case .unknown(let value): return .unknown(value) + } +} + +private func isErrorResponsePart(_ part: AppendableResponsePart) -> Bool { + guard case .unknown(let raw) = part, + let value = raw.value as? [String: Any] else { + return false + } + return value["kind"] as? String == "error" +} + /// Whether an entry blocks on the *user*. /// /// `.toolClientExecution` is work delegated to a client, not a prompt: the call @@ -164,10 +184,10 @@ 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 { + guard !isErrorResponsePart(a.part) else { return state } - activeTurn.responseParts.append(a.part) + activeTurn.responseParts.append(responsePart(a.part)) var next = state next.activeTurn = activeTurn return next diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index 921d9e8e6..d6d91b144 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -352,7 +352,7 @@ InputRequestResponsePart { ErrorResponsePart { kind: 'error' error: ErrorInfo - resumable?: true + resumable?: boolean } ``` diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 2ac9fbee6..2484f9440 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -174,7 +174,7 @@ 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 is resumable, a client may dispatch `chat/turnResume` to continue the same turn without another user message. +- 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. @@ -183,7 +183,7 @@ All actions dispatched on this channel travel on `ActionEnvelope`s whose `channe 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. Clients decide whether and how to present +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 diff --git a/schema/actions.schema.json b/schema/actions.schema.json index b38d3b1bb..a489fdb77 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -751,8 +751,8 @@ "description": "Turn identifier" }, "part": { - "$ref": "#/$defs/ResponsePart", - "description": "Response part to append; error parts are ignored." + "$ref": "#/$defs/AppendableResponsePart", + "description": "Non-error response part to append." }, "_meta": { "type": "object", @@ -5586,7 +5586,7 @@ }, "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 present, 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.", + "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", @@ -5597,7 +5597,8 @@ "description": "Error details." }, "resumable": { - "description": "Whether the host can resume the turn from this error." + "type": "boolean", + "description": "Whether the host can resume the turn from this error. Only `true` enables resume." } }, "required": [ @@ -7407,7 +7408,7 @@ ], "description": "An attachment associated with a {@link Message}." }, - "ResponsePart": { + "AppendableResponsePart": { "oneOf": [ { "$ref": "#/$defs/MarkdownResponsePart" @@ -7426,11 +7427,20 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + } + ], + "description": "A response part that may be appended while a turn is active with\n`chat/responsePart`.\n\nErrors are excluded because they must be appended atomically with the\nterminal `chat/error` transition." + }, + "ResponsePart": { + "oneOf": [ + { + "$ref": "#/$defs/AppendableResponsePart" }, { "$ref": "#/$defs/ErrorResponsePart" } - ] + ], + "description": "Any durable part of a turn's response stream." }, "ToolCallRiskAssessment": { "oneOf": [ diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 8bf103aec..ae4bb6322 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -4875,7 +4875,7 @@ }, "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 present, 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.", + "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", @@ -4886,7 +4886,8 @@ "description": "Error details." }, "resumable": { - "description": "Whether the host can resume the turn from this error." + "type": "boolean", + "description": "Whether the host can resume the turn from this error. Only `true` enables resume." } }, "required": [ @@ -7146,8 +7147,8 @@ "description": "Turn identifier" }, "part": { - "$ref": "#/$defs/ResponsePart", - "description": "Response part to append; error parts are ignored." + "$ref": "#/$defs/AppendableResponsePart", + "description": "Non-error response part to append." }, "_meta": { "type": "object", @@ -9258,27 +9259,13 @@ "ResponsePart": { "oneOf": [ { - "$ref": "#/$defs/MarkdownResponsePart" - }, - { - "$ref": "#/$defs/ResourceResponsePart" - }, - { - "$ref": "#/$defs/ToolCallResponsePart" - }, - { - "$ref": "#/$defs/ReasoningResponsePart" - }, - { - "$ref": "#/$defs/SystemNotificationResponsePart" - }, - { - "$ref": "#/$defs/InputRequestResponsePart" + "$ref": "#/$defs/AppendableResponsePart" }, { "$ref": "#/$defs/ErrorResponsePart" } - ] + ], + "description": "Any durable part of a turn's response stream." }, "TurnState": { "enum": [ @@ -9443,6 +9430,29 @@ "type": "string", "description": "Discriminant for {@link ResourceChange.type}." }, + "AppendableResponsePart": { + "oneOf": [ + { + "$ref": "#/$defs/MarkdownResponsePart" + }, + { + "$ref": "#/$defs/ResourceResponsePart" + }, + { + "$ref": "#/$defs/ToolCallResponsePart" + }, + { + "$ref": "#/$defs/ReasoningResponsePart" + }, + { + "$ref": "#/$defs/SystemNotificationResponsePart" + }, + { + "$ref": "#/$defs/InputRequestResponsePart" + } + ], + "description": "A response part that may be appended while a turn is active with\n`chat/responsePart`.\n\nErrors are excluded because they must be appended atomically with the\nterminal `chat/error` transition." + }, "PendingMessageKind": { "enum": [ "steering", diff --git a/schema/errors.schema.json b/schema/errors.schema.json index f6d64e1b9..4f9acfa3a 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -3471,7 +3471,7 @@ }, "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 present, 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.", + "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", @@ -3482,7 +3482,8 @@ "description": "Error details." }, "resumable": { - "description": "Whether the host can resume the turn from this error." + "type": "boolean", + "description": "Whether the host can resume the turn from this error. Only `true` enables resume." } }, "required": [ @@ -6838,27 +6839,13 @@ "ResponsePart": { "oneOf": [ { - "$ref": "#/$defs/MarkdownResponsePart" - }, - { - "$ref": "#/$defs/ResourceResponsePart" - }, - { - "$ref": "#/$defs/ToolCallResponsePart" - }, - { - "$ref": "#/$defs/ReasoningResponsePart" - }, - { - "$ref": "#/$defs/SystemNotificationResponsePart" - }, - { - "$ref": "#/$defs/InputRequestResponsePart" + "$ref": "#/$defs/AppendableResponsePart" }, { "$ref": "#/$defs/ErrorResponsePart" } - ] + ], + "description": "Any durable part of a turn's response stream." }, "TurnState": { "enum": [ @@ -7460,6 +7447,29 @@ ], "description": "Identifies the file or range a {@link ChangesetOperation} should act on.\n\nThe `kind` MUST match one of the operation's declared\n{@link ChangesetOperation.scopes}." }, + "AppendableResponsePart": { + "oneOf": [ + { + "$ref": "#/$defs/MarkdownResponsePart" + }, + { + "$ref": "#/$defs/ResourceResponsePart" + }, + { + "$ref": "#/$defs/ToolCallResponsePart" + }, + { + "$ref": "#/$defs/ReasoningResponsePart" + }, + { + "$ref": "#/$defs/SystemNotificationResponsePart" + }, + { + "$ref": "#/$defs/InputRequestResponsePart" + } + ], + "description": "A response part that may be appended while a turn is active with\n`chat/responsePart`.\n\nErrors are excluded because they must be appended atomically with the\nterminal `chat/error` transition." + }, "ActionOrigin": { "type": "object", "description": "Identifies the client that originally dispatched an action.", @@ -8156,8 +8166,8 @@ "description": "Turn identifier" }, "part": { - "$ref": "#/$defs/ResponsePart", - "description": "Response part to append; error parts are ignored." + "$ref": "#/$defs/AppendableResponsePart", + "description": "Non-error response part to append." }, "_meta": { "type": "object", diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 0a982709b..161ef91de 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -3634,7 +3634,7 @@ }, "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 present, 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.", + "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", @@ -3645,7 +3645,8 @@ "description": "Error details." }, "resumable": { - "description": "Whether the host can resume the turn from this error." + "type": "boolean", + "description": "Whether the host can resume the turn from this error. Only `true` enables resume." } }, "required": [ @@ -5516,27 +5517,13 @@ "ResponsePart": { "oneOf": [ { - "$ref": "#/$defs/MarkdownResponsePart" - }, - { - "$ref": "#/$defs/ResourceResponsePart" - }, - { - "$ref": "#/$defs/ToolCallResponsePart" - }, - { - "$ref": "#/$defs/ReasoningResponsePart" - }, - { - "$ref": "#/$defs/SystemNotificationResponsePart" - }, - { - "$ref": "#/$defs/InputRequestResponsePart" + "$ref": "#/$defs/AppendableResponsePart" }, { "$ref": "#/$defs/ErrorResponsePart" } - ] + ], + "description": "Any durable part of a turn's response stream." }, "TurnState": { "enum": [ @@ -5750,6 +5737,29 @@ ], "type": "string", "description": "Discriminant for {@link ResourceChange.type}." + }, + "AppendableResponsePart": { + "oneOf": [ + { + "$ref": "#/$defs/MarkdownResponsePart" + }, + { + "$ref": "#/$defs/ResourceResponsePart" + }, + { + "$ref": "#/$defs/ToolCallResponsePart" + }, + { + "$ref": "#/$defs/ReasoningResponsePart" + }, + { + "$ref": "#/$defs/SystemNotificationResponsePart" + }, + { + "$ref": "#/$defs/InputRequestResponsePart" + } + ], + "description": "A response part that may be appended while a turn is active with\n`chat/responsePart`.\n\nErrors are excluded because they must be appended atomically with the\nterminal `chat/error` transition." } } } diff --git a/schema/state.schema.json b/schema/state.schema.json index e1a53d91b..46f02479e 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -3382,7 +3382,7 @@ }, "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 present, 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.", + "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", @@ -3393,7 +3393,8 @@ "description": "Error details." }, "resumable": { - "description": "Whether the host can resume the turn from this error." + "type": "boolean", + "description": "Whether the host can resume the turn from this error. Only `true` enables resume." } }, "required": [ @@ -5203,7 +5204,7 @@ ], "description": "An attachment associated with a {@link Message}." }, - "ResponsePart": { + "AppendableResponsePart": { "oneOf": [ { "$ref": "#/$defs/MarkdownResponsePart" @@ -5222,11 +5223,20 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + } + ], + "description": "A response part that may be appended while a turn is active with\n`chat/responsePart`.\n\nErrors are excluded because they must be appended atomically with the\nterminal `chat/error` transition." + }, + "ResponsePart": { + "oneOf": [ + { + "$ref": "#/$defs/AppendableResponsePart" }, { "$ref": "#/$defs/ErrorResponsePart" } - ] + ], + "description": "Any durable part of a turn's response stream." }, "ToolCallRiskAssessment": { "oneOf": [ diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index cf698be96..48456a411 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -832,10 +832,10 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'ResourceChange' }, ]; -const RESPONSE_PART_UNION: UnionConfig = { - name: 'ResponsePart', +const APPENDABLE_RESPONSE_PART_UNION: UnionConfig = { + name: 'AppendableResponsePart', discriminantField: 'kind', - doc: 'ResponsePart is a single part of a response stream (text, tool call, reasoning, content reference).', + doc: 'AppendableResponsePart is a non-error part that may be appended while a turn is active.', variants: [ { variantName: 'Markdown', innerType: 'MarkdownResponsePart', wireValue: 'markdown' }, { variantName: 'ContentRef', innerType: 'ResourceResponsePart', wireValue: 'contentRef' }, @@ -843,6 +843,16 @@ const RESPONSE_PART_UNION: UnionConfig = { { variantName: 'Reasoning', innerType: 'ReasoningResponsePart', wireValue: 'reasoning' }, { variantName: 'SystemNotification', innerType: 'SystemNotificationResponsePart', wireValue: 'systemNotification' }, { variantName: 'InputRequest', innerType: 'InputRequestResponsePart', wireValue: 'inputRequest' }, + ], + unknown: true, +}; + +const RESPONSE_PART_UNION: UnionConfig = { + name: 'ResponsePart', + discriminantField: 'kind', + doc: 'ResponsePart is a single part of a response stream (text, tool call, reasoning, content reference).', + variants: [ + ...APPENDABLE_RESPONSE_PART_UNION.variants, { variantName: 'Error', innerType: 'ErrorResponsePart', wireValue: 'error' }, ], unknown: true, @@ -1321,6 +1331,8 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push('// ─── Discriminated Unions ─────────────────────────────────────────────\n'); + lines.push(generateDiscriminatedUnion(APPENDABLE_RESPONSE_PART_UNION)); + lines.push(''); lines.push(generateDiscriminatedUnion(RESPONSE_PART_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(TOOL_CALL_STATE_UNION)); @@ -2059,6 +2071,7 @@ function checkExhaustiveness(project: Project): void { 'StateAction', 'ActionEnvelope', 'ActionOrigin', + 'AppendableResponsePart', 'ResponsePart', 'ToolResultContent', 'SessionToolCallApprovedAction', diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index bdd1e22c3..ba72d6d64 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -961,8 +961,8 @@ const STATE_STRUCTS = [ 'ResourceWatchState', 'ResourceChange', ]; -const RESPONSE_PART_UNION: UnionConfig = { - name: 'ResponsePart', +const APPENDABLE_RESPONSE_PART_UNION: UnionConfig = { + name: 'AppendableResponsePart', discriminantField: 'kind', variants: [ { caseName: 'Markdown', structName: 'MarkdownResponsePart', discriminantValue: 'markdown' }, @@ -971,6 +971,15 @@ const RESPONSE_PART_UNION: UnionConfig = { { caseName: 'Reasoning', structName: 'ReasoningResponsePart', discriminantValue: 'reasoning' }, { caseName: 'SystemNotification', structName: 'SystemNotificationResponsePart', discriminantValue: 'systemNotification' }, { caseName: 'InputRequest', structName: 'InputRequestResponsePart', discriminantValue: 'inputRequest' }, + ], + unknown: true, +}; + +const RESPONSE_PART_UNION: UnionConfig = { + name: 'ResponsePart', + discriminantField: 'kind', + variants: [ + ...APPENDABLE_RESPONSE_PART_UNION.variants, { caseName: 'Error', structName: 'ErrorResponsePart', discriminantValue: 'error' }, ], unknown: true, @@ -1267,6 +1276,8 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateChatOriginKotlin()); lines.push(''); + lines.push(generateDiscriminatedUnion(APPENDABLE_RESPONSE_PART_UNION)); + lines.push(''); lines.push(generateDiscriminatedUnion(RESPONSE_PART_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(TOOL_CALL_STATE_UNION)); @@ -2083,6 +2094,7 @@ function checkExhaustiveness(project: Project): void { 'StateAction', // StateAction enum in generateActionsFile() 'ActionEnvelope', // generateDataClassFromInterface() call in generateActionsFile() 'ActionOrigin', // generateDataClassFromInterface() call in generateActionsFile() + 'AppendableResponsePart', // APPENDABLE_RESPONSE_PART_UNION discriminated union 'ResponsePart', // RESPONSE_PART_UNION discriminated union 'ToolResultContent', // generateToolResultContentUnion() 'SessionToolCallApprovedAction', // merged into SessionToolCallConfirmedAction diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 4eae61752..73c3edb60 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")]'); @@ -815,10 +821,10 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'ResourceChange' }, ]; -const RESPONSE_PART_UNION: UnionConfig = { - name: 'ResponsePart', +const APPENDABLE_RESPONSE_PART_UNION: UnionConfig = { + name: 'AppendableResponsePart', discriminantField: 'kind', - doc: 'A single part of a response stream (text, tool call, reasoning, content reference).', + doc: 'A non-error part that may be appended while a turn is active.', variants: [ { variantName: 'Markdown', innerType: 'MarkdownResponsePart', wireValue: 'markdown' }, { variantName: 'ContentRef', innerType: 'ResourceResponsePart', wireValue: 'contentRef' }, @@ -826,6 +832,16 @@ const RESPONSE_PART_UNION: UnionConfig = { { variantName: 'Reasoning', innerType: 'ReasoningResponsePart', wireValue: 'reasoning' }, { variantName: 'SystemNotification', innerType: 'SystemNotificationResponsePart', wireValue: 'systemNotification' }, { variantName: 'InputRequest', innerType: 'InputRequestResponsePart', wireValue: 'inputRequest' }, + ], + unknown: true, +}; + +const RESPONSE_PART_UNION: UnionConfig = { + name: 'ResponsePart', + discriminantField: 'kind', + doc: 'A single part of a response stream (text, tool call, reasoning, content reference).', + variants: [ + ...APPENDABLE_RESPONSE_PART_UNION.variants, { variantName: 'Error', innerType: 'ErrorResponsePart', wireValue: 'error' }, ], unknown: true, @@ -1159,6 +1175,8 @@ function generateStateFile(project: Project): string { lines.push('// ─── Discriminated Unions ─────────────────────────────────────────────\n'); lines.push(generateChatOrigin()); lines.push(''); + lines.push(generateDiscriminatedUnion(APPENDABLE_RESPONSE_PART_UNION)); + lines.push(''); lines.push(generateDiscriminatedUnion(RESPONSE_PART_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(TOOL_CALL_STATE_UNION)); @@ -1330,10 +1348,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, 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('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, AppendableResponsePart, 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 @@ -1382,7 +1439,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}`); @@ -1891,6 +1953,7 @@ function checkExhaustiveness(project: Project): void { 'StateAction', 'ActionEnvelope', 'ActionOrigin', + 'AppendableResponsePart', 'ResponsePart', 'ToolResultContent', 'SessionToolCallApprovedAction', diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index fab16083f..7c05f10fc 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -670,13 +670,9 @@ const STATE_STRUCTS = [ 'ResourceWatchState', 'ResourceChange', ]; -const RESPONSE_PART_UNION: UnionConfig = { - name: 'ResponsePart', +const APPENDABLE_RESPONSE_PART_UNION: UnionConfig = { + name: 'AppendableResponsePart', discriminantField: 'kind', - // Open union: an unrecognized `kind` (e.g. a future protocol part type) is - // preserved as a raw AnyCodable passthrough and re-encoded verbatim so that - // snapshot decode and round-trip both succeed and delta reducers that target - // other parts (by id) still work correctly. Mirrors .NET allowUnknown. allowUnknown: true, variants: [ { caseName: 'markdown', structName: 'MarkdownResponsePart', discriminantValue: 'markdown' }, @@ -685,6 +681,19 @@ const RESPONSE_PART_UNION: UnionConfig = { { caseName: 'reasoning', structName: 'ReasoningResponsePart', discriminantValue: 'reasoning' }, { caseName: 'systemNotification', structName: 'SystemNotificationResponsePart', discriminantValue: 'systemNotification' }, { caseName: 'inputRequest', structName: 'InputRequestResponsePart', discriminantValue: 'inputRequest' }, + ], +}; + +const RESPONSE_PART_UNION: UnionConfig = { + name: 'ResponsePart', + discriminantField: 'kind', + // Open union: an unrecognized `kind` (e.g. a future protocol part type) is + // preserved as a raw AnyCodable passthrough and re-encoded verbatim so that + // snapshot decode and round-trip both succeed and delta reducers that target + // other parts (by id) still work correctly. Mirrors .NET allowUnknown. + allowUnknown: true, + variants: [ + ...APPENDABLE_RESPONSE_PART_UNION.variants, { caseName: 'error', structName: 'ErrorResponsePart', discriminantValue: 'error' }, ], }; @@ -1160,6 +1169,8 @@ function generateStateFile(project: Project): string { lines.push('// MARK: - Discriminated Unions\n'); lines.push(generateChatOriginSwift()); lines.push(''); + lines.push(generateDiscriminatedUnion(APPENDABLE_RESPONSE_PART_UNION)); + lines.push(''); lines.push(generateDiscriminatedUnion(RESPONSE_PART_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(TOOL_CALL_STATE_UNION)); @@ -2101,6 +2112,7 @@ function checkExhaustiveness(project: Project): void { 'StateAction', // StateAction enum in generateActionsFile() 'ActionEnvelope', // generateStructFromInterface() call in generateActionsFile() 'ActionOrigin', // generateStructFromInterface() call in generateActionsFile() + 'AppendableResponsePart', // APPENDABLE_RESPONSE_PART_UNION discriminated union 'ResponsePart', // RESPONSE_PART_UNION discriminated union 'ToolResultContent', // TOOL_RESULT_CONTENT_UNION discriminated union 'SessionToolCallApprovedAction', // merged into SessionToolCallConfirmedAction diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index 07bfb9468..e978781a2 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -9,7 +9,7 @@ import type { StringOrMarkdown, FileEdit, UsageInfo, URI } from '../common/state import type { McpAuthRequirement } from '../channels-session/state.js'; import type { Message, - ResponsePart, + AppendableResponsePart, ToolCallResult, ToolResultContent, ChatInputAnswer, @@ -129,8 +129,8 @@ export interface ChatResponsePartAction { type: ActionType.ChatResponsePart; /** Turn identifier */ turnId: string; - /** Response part to append; error parts are ignored. */ - part: ResponsePart; + /** Non-error response part to append. */ + part: AppendableResponsePart; /** * Additional provider-specific metadata for this action. * diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index 8be4c98c3..f994c1205 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -127,6 +127,10 @@ function hasResumableError(turn: Turn): boolean { 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; @@ -393,7 +397,7 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st if (!state.activeTurn || state.activeTurn.id !== action.turnId) { return state; } - if (action.part.kind === ResponsePartKind.Error) { + if (isErrorResponsePart(action.part)) { return state; } return { diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index 0fbe24c52..322966b3d 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -916,16 +916,28 @@ export interface ReasoningResponsePart { } /** + * A response part that may be appended while a turn is active with + * `chat/responsePart`. + * + * Errors are excluded because they must be appended atomically with the + * terminal `chat/error` transition. + * * @category Response Parts */ -export type ResponsePart = +export type AppendableResponsePart = | MarkdownResponsePart | ResourceResponsePart | ToolCallResponsePart | ReasoningResponsePart | SystemNotificationResponsePart - | InputRequestResponsePart - | ErrorResponsePart; + | InputRequestResponsePart; + +/** + * Any durable part of a turn's response stream. + * + * @category Response Parts + */ +export type ResponsePart = AppendableResponsePart | ErrorResponsePart; /** * A live or resolved input request (elicitation) in the turn response stream. @@ -963,7 +975,7 @@ export interface InputRequestResponsePart { * 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 present, a client may dispatch `chat/turnResume` + * 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. * @@ -974,8 +986,8 @@ export interface ErrorResponsePart { kind: ResponsePartKind.Error; /** Error details. */ error: ErrorInfo; - /** Whether the host can resume the turn from this error. */ - resumable?: true; + /** Whether the host can resume the turn from this error. Only `true` enables resume. */ + resumable?: boolean; } /** 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 index 8e4f0b8df..13265b7af 100644 --- 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 @@ -41,6 +41,10 @@ "type": "chat/turnComplete", "turnId": "turn-1", "duration": 2000 + }, + { + "type": "chat/turnResume", + "turnId": "turn-1" } ], "expected": { 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 + } + } + ] +} From c307fe778eae71405b5cec06eddcd24e4754291c Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Thu, 13 Aug 2026 11:30:30 -0700 Subject: [PATCH 5/5] chat: remove appendable response part union Use ResponsePart directly for chat/responsePart while retaining reducer guards that ignore error parts outside the atomic chat/error transition.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahp/reducers.go | 32 +------ clients/go/ahptypes/actions.generated.go | 4 +- clients/go/ahptypes/state.generated.go | 88 ------------------- clients/go/examples/reducers_demo/main.go | 2 +- .../microsoft/agenthostprotocol/Reducers.kt | 19 +--- .../generated/Actions.generated.kt | 4 +- .../generated/State.generated.kt | 65 -------------- clients/rust/crates/ahp-types/src/actions.rs | 8 +- clients/rust/crates/ahp-types/src/state.rs | 22 ----- clients/rust/crates/ahp/src/reducers.rs | 38 ++------ .../Generated/Actions.generated.swift | 6 +- .../Generated/State.generated.swift | 49 ----------- .../Sources/AgentHostProtocol/Reducers.swift | 24 +---- schema/actions.schema.json | 17 +--- schema/commands.schema.json | 47 ++++------ schema/errors.schema.json | 47 ++++------ schema/notifications.schema.json | 43 ++++----- schema/state.schema.json | 13 +-- scripts/generate-go.ts | 19 +--- scripts/generate-kotlin.ts | 16 +--- scripts/generate-rust.ts | 21 +---- scripts/generate-swift.ts | 24 ++--- types/channels-chat/actions.ts | 6 +- types/channels-chat/state.ts | 18 +--- 24 files changed, 107 insertions(+), 525 deletions(-) diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index cc8f43d5b..7614f267d 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -472,33 +472,6 @@ func updateResponsePart(state *ahptypes.ChatState, turnID, partID string, update return ReduceOutcomeNoOp } -func responsePartFromAppendable(part ahptypes.AppendableResponsePart) (ahptypes.ResponsePart, bool) { - switch value := part.Value.(type) { - case *ahptypes.MarkdownResponsePart: - return ahptypes.ResponsePart{Value: value}, true - case *ahptypes.ResourceResponsePart: - return ahptypes.ResponsePart{Value: value}, true - case *ahptypes.ToolCallResponsePart: - return ahptypes.ResponsePart{Value: value}, true - case *ahptypes.ReasoningResponsePart: - return ahptypes.ResponsePart{Value: value}, true - case *ahptypes.SystemNotificationResponsePart: - return ahptypes.ResponsePart{Value: value}, true - case *ahptypes.InputRequestResponsePart: - return ahptypes.ResponsePart{Value: value}, true - case *ahptypes.AppendableResponsePartUnknown: - var discriminator struct { - Kind string `json:"kind"` - } - if json.Unmarshal(value.Raw, &discriminator) != nil || discriminator.Kind == "error" { - return ahptypes.ResponsePart{}, false - } - return ahptypes.ResponsePart{Value: &ahptypes.ResponsePartUnknown{Raw: value.Raw}}, true - default: - return ahptypes.ResponsePart{}, false - } -} - // ─── Root Reducer ────────────────────────────────────────────────────── // ApplyActionToRoot applies action to the [ahptypes.RootState] in @@ -553,11 +526,10 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R if state.ActiveTurn == nil || state.ActiveTurn.Id != a.TurnId { return ReduceOutcomeNoOp } - part, ok := responsePartFromAppendable(a.Part) - if !ok { + if _, ok := a.Part.Value.(*ahptypes.ErrorResponsePart); ok { return ReduceOutcomeNoOp } - state.ActiveTurn.ResponseParts = append(state.ActiveTurn.ResponseParts, part) + state.ActiveTurn.ResponseParts = append(state.ActiveTurn.ResponseParts, a.Part) return ReduceOutcomeApplied case *ahptypes.ChatTurnCompleteAction: return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateComplete, nil, nil) diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index fc92b8eae..288e742d8 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -265,8 +265,8 @@ type ChatResponsePartAction struct { Type ActionType `json:"type"` // Turn identifier TurnId string `json:"turnId"` - // Non-error response part to append. - Part AppendableResponsePart `json:"part"` + // Response part to append; error parts are ignored. + Part ResponsePart `json:"part"` // Additional provider-specific metadata for this action. // // Clients MAY look for well-known keys here to provide enhanced UI, and diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index b386881ba..c331d31db 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -3607,94 +3607,6 @@ func (t *ToolInput) UnmarshalJSON(data []byte) error { // ─── Discriminated Unions ───────────────────────────────────────────── -// AppendableResponsePart is a non-error part that may be appended while a turn is active. -type AppendableResponsePart struct { - Value isAppendableResponsePart -} - -// isAppendableResponsePart is the marker interface implemented by every -// concrete variant of AppendableResponsePart. -type isAppendableResponsePart interface{ isAppendableResponsePart() } - -func (*MarkdownResponsePart) isAppendableResponsePart() {} -func (*ResourceResponsePart) isAppendableResponsePart() {} -func (*ToolCallResponsePart) isAppendableResponsePart() {} -func (*ReasoningResponsePart) isAppendableResponsePart() {} -func (*SystemNotificationResponsePart) isAppendableResponsePart() {} -func (*InputRequestResponsePart) isAppendableResponsePart() {} - -// AppendableResponsePartUnknown carries an unrecognized AppendableResponsePart 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 AppendableResponsePartUnknown struct { - Raw json.RawMessage -} - -func (*AppendableResponsePartUnknown) isAppendableResponsePart() {} - -// UnmarshalJSON decodes the variant indicated by the "kind" discriminator. -func (u *AppendableResponsePart) UnmarshalJSON(data []byte) error { - disc, _, err := readDiscriminator(data, "kind") - if err != nil { - return err - } - switch disc { - case "markdown": - var value MarkdownResponsePart - if err := json.Unmarshal(data, &value); err != nil { - return err - } - u.Value = &value - case "contentRef": - var value ResourceResponsePart - if err := json.Unmarshal(data, &value); err != nil { - return err - } - u.Value = &value - case "toolCall": - var value ToolCallResponsePart - if err := json.Unmarshal(data, &value); err != nil { - return err - } - u.Value = &value - case "reasoning": - var value ReasoningResponsePart - if err := json.Unmarshal(data, &value); err != nil { - return err - } - u.Value = &value - case "systemNotification": - var value SystemNotificationResponsePart - if err := json.Unmarshal(data, &value); err != nil { - return err - } - u.Value = &value - case "inputRequest": - var value InputRequestResponsePart - if err := json.Unmarshal(data, &value); err != nil { - return err - } - u.Value = &value - default: - raw := make(json.RawMessage, len(data)) - copy(raw, data) - u.Value = &AppendableResponsePartUnknown{Raw: raw} - } - return nil -} - -// MarshalJSON encodes the active variant back to JSON. -func (u AppendableResponsePart) MarshalJSON() ([]byte, error) { - if unk, ok := u.Value.(*AppendableResponsePartUnknown); ok { - if len(unk.Raw) == 0 { - return []byte("null"), nil - } - return unk.Raw, nil - } - if u.Value == nil { - return []byte("null"), nil - } - return json.Marshal(u.Value) -} - // ResponsePart is a single part of a response stream (text, tool call, reasoning, content reference). type ResponsePart struct { Value isResponsePart diff --git a/clients/go/examples/reducers_demo/main.go b/clients/go/examples/reducers_demo/main.go index d9818a6c7..139d8c637 100644 --- a/clients/go/examples/reducers_demo/main.go +++ b/clients/go/examples/reducers_demo/main.go @@ -28,7 +28,7 @@ func main() { {Value: &ahptypes.ChatResponsePartAction{ Type: ahptypes.ActionTypeChatResponsePart, TurnId: "t1", - Part: ahptypes.AppendableResponsePart{Value: &ahptypes.MarkdownResponsePart{ + Part: ahptypes.ResponsePart{Value: &ahptypes.MarkdownResponsePart{ Kind: ahptypes.ResponsePartKindMarkdown, Id: "p1", Content: "Hi ", 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 9e0ace5e9..863ff16d1 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -11,8 +11,6 @@ package com.microsoft.agenthostprotocol import com.microsoft.agenthostprotocol.generated.* import java.time.Instant import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull // ─── Reducer Interface ────────────────────────────────────────────────────── @@ -30,19 +28,6 @@ public fun interface Reducer { public fun reduce(state: S, action: A): S } -private fun AppendableResponsePart.toResponsePart(): ResponsePart = when (this) { - is AppendableResponsePartMarkdown -> ResponsePartMarkdown(value) - is AppendableResponsePartContentRef -> ResponsePartContentRef(value) - is AppendableResponsePartToolCall -> ResponsePartToolCall(value) - is AppendableResponsePartReasoning -> ResponsePartReasoning(value) - is AppendableResponsePartSystemNotification -> ResponsePartSystemNotification(value) - is AppendableResponsePartInputRequest -> ResponsePartInputRequest(value) - is AppendableResponsePartUnknown -> ResponsePartUnknown(raw) -} - -private fun AppendableResponsePart.isErrorResponsePart(): Boolean = - this is AppendableResponsePartUnknown && (raw["kind"] as? JsonPrimitive)?.contentOrNull == "error" - /** Pure root reducer as a [Reducer] instance. Delegates to [rootReducer]. */ public object RootReducer : Reducer { override fun reduce(state: RootState, action: StateAction): RootState = @@ -882,11 +867,11 @@ 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 || a.part.isErrorResponsePart()) { + if (activeTurn == null || activeTurn.id != a.turnId || a.part is ResponsePartError) { state } else { state.copy( - activeTurn = activeTurn.copy(responseParts = activeTurn.responseParts + a.part.toResponsePart()), + activeTurn = activeTurn.copy(responseParts = activeTurn.responseParts + a.part), ) } } diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt index 17e3911b5..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 @@ -365,9 +365,9 @@ data class ChatResponsePartAction( */ val turnId: String, /** - * Non-error response part to append. + * Response part to append; error parts are ignored. */ - val part: AppendableResponsePart, + val part: ResponsePart, /** * Additional provider-specific metadata for this action. * 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 810aaab67..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 @@ -4784,71 +4784,6 @@ internal object ChatOriginSerializer : KSerializer { } } -@Serializable(with = AppendableResponsePartSerializer::class) -sealed interface AppendableResponsePart - -@JvmInline -value class AppendableResponsePartMarkdown(val value: MarkdownResponsePart) : AppendableResponsePart -@JvmInline -value class AppendableResponsePartContentRef(val value: ResourceResponsePart) : AppendableResponsePart -@JvmInline -value class AppendableResponsePartToolCall(val value: ToolCallResponsePart) : AppendableResponsePart -@JvmInline -value class AppendableResponsePartReasoning(val value: ReasoningResponsePart) : AppendableResponsePart -@JvmInline -value class AppendableResponsePartSystemNotification(val value: SystemNotificationResponsePart) : AppendableResponsePart -@JvmInline -value class AppendableResponsePartInputRequest(val value: InputRequestResponsePart) : AppendableResponsePart -/** - * Forward-compat catch-all for unknown AppendableResponsePart discriminators. - * - * Older clients may receive newer wire variants they don't recognise; capturing - * the raw `JsonObject` lets such payloads round-trip through the client unchanged. - * Reducers handle this variant conservatively on a per-union basis (typically - * as a no-op, but see `Reducers.kt` for the exact treatment). - */ -@JvmInline -value class AppendableResponsePartUnknown(val raw: JsonObject) : AppendableResponsePart - -internal object AppendableResponsePartSerializer : KSerializer { - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("AppendableResponsePart") - - override fun deserialize(decoder: Decoder): AppendableResponsePart { - val input = decoder as? JsonDecoder - ?: error("AppendableResponsePart can only be deserialized from JSON") - val element = input.decodeJsonElement() - val obj = element as? JsonObject - ?: error("Expected JsonObject for AppendableResponsePart") - val discriminant = (obj["kind"] as? JsonPrimitive)?.content - ?: return AppendableResponsePartUnknown(obj) - return when (discriminant) { - "markdown" -> AppendableResponsePartMarkdown(input.json.decodeFromJsonElement(MarkdownResponsePart.serializer(), element)) - "contentRef" -> AppendableResponsePartContentRef(input.json.decodeFromJsonElement(ResourceResponsePart.serializer(), element)) - "toolCall" -> AppendableResponsePartToolCall(input.json.decodeFromJsonElement(ToolCallResponsePart.serializer(), element)) - "reasoning" -> AppendableResponsePartReasoning(input.json.decodeFromJsonElement(ReasoningResponsePart.serializer(), element)) - "systemNotification" -> AppendableResponsePartSystemNotification(input.json.decodeFromJsonElement(SystemNotificationResponsePart.serializer(), element)) - "inputRequest" -> AppendableResponsePartInputRequest(input.json.decodeFromJsonElement(InputRequestResponsePart.serializer(), element)) - else -> AppendableResponsePartUnknown(obj) - } - } - - override fun serialize(encoder: Encoder, value: AppendableResponsePart) { - val output = encoder as? JsonEncoder - ?: error("AppendableResponsePart can only be serialized to JSON") - val element: JsonElement = when (value) { - is AppendableResponsePartMarkdown -> output.json.encodeToJsonElement(MarkdownResponsePart.serializer(), value.value) - is AppendableResponsePartContentRef -> output.json.encodeToJsonElement(ResourceResponsePart.serializer(), value.value) - is AppendableResponsePartToolCall -> output.json.encodeToJsonElement(ToolCallResponsePart.serializer(), value.value) - is AppendableResponsePartReasoning -> output.json.encodeToJsonElement(ReasoningResponsePart.serializer(), value.value) - is AppendableResponsePartSystemNotification -> output.json.encodeToJsonElement(SystemNotificationResponsePart.serializer(), value.value) - is AppendableResponsePartInputRequest -> output.json.encodeToJsonElement(InputRequestResponsePart.serializer(), value.value) - is AppendableResponsePartUnknown -> value.raw - } - output.encodeJsonElement(element) - } -} - @Serializable(with = ResponsePartSerializer::class) sealed interface ResponsePart diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 0db1a22f1..5e5cfe0c0 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -13,8 +13,8 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; #[allow(unused_imports)] use crate::state::{ - AgentInfo, AgentSelection, Annotation, AnnotationEntry, AppendableResponsePart, Changeset, - ChangesetFile, ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, + AgentInfo, AgentSelection, Annotation, AnnotationEntry, Changeset, ChangesetFile, + ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ChatSummary, ConfirmationOption, ContentRef, Customization, ErrorInfo, ErrorResponsePart, McpAuthRequirement, McpServerState, Message, ModelSelection, PendingMessageKind, ResponsePart, @@ -388,8 +388,8 @@ pub struct ChatDeltaAction { pub struct ChatResponsePartAction { /// Turn identifier pub turn_id: String, - /// Non-error response part to append. - pub part: AppendableResponsePart, + /// Response part to append; error parts are ignored. + pub part: ResponsePart, /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 13c880a50..cbe2a4a89 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -4329,28 +4329,6 @@ pub enum ChatOrigin { Unknown(serde_json::Value), } -/// A non-error part that may be appended while a turn is active. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "kind")] -pub enum AppendableResponsePart { - #[serde(rename = "markdown")] - Markdown(MarkdownResponsePart), - #[serde(rename = "contentRef")] - ContentRef(ResourceResponsePart), - #[serde(rename = "toolCall")] - ToolCall(Box), - #[serde(rename = "reasoning")] - Reasoning(ReasoningResponsePart), - #[serde(rename = "systemNotification")] - SystemNotification(SystemNotificationResponsePart), - #[serde(rename = "inputRequest")] - InputRequest(InputRequestResponsePart), - /// Unknown or future variant — preserved as raw JSON for round-trip fidelity. - /// Reducers treat this as a no-op. - #[serde(untagged)] - Unknown(serde_json::Value), -} - /// A single part of a response stream (text, tool call, reasoning, content reference). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind")] diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 469fbaef5..7ea11906e 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -58,11 +58,11 @@ use ahp_types::actions::{ ChatTurnStartedAction, StateAction, }; use ahp_types::state::{ - ActiveTurn, AnnotationsState, AppendableResponsePart, ChangesetOperationStatus, ChangesetState, - ChangesetStatus, ChatInputRequest, ChatState, ChildCustomization, ConfirmationOption, - Customization, ErrorResponsePart, InputRequestResponsePart, McpServerStartingState, - McpServerState, McpServerStoppedState, PendingMessage, PendingMessageKind, ResourceWatchState, - ResponsePart, RootState, SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, + ActiveTurn, AnnotationsState, ChangesetOperationStatus, ChangesetState, ChangesetStatus, + 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, @@ -613,28 +613,6 @@ where ReduceOutcome::NoOp } -fn appendable_response_part(part: &AppendableResponsePart) -> ResponsePart { - match part { - AppendableResponsePart::Markdown(value) => ResponsePart::Markdown(value.clone()), - AppendableResponsePart::ContentRef(value) => ResponsePart::ContentRef(value.clone()), - AppendableResponsePart::ToolCall(value) => ResponsePart::ToolCall(value.clone()), - AppendableResponsePart::Reasoning(value) => ResponsePart::Reasoning(value.clone()), - AppendableResponsePart::SystemNotification(value) => { - ResponsePart::SystemNotification(value.clone()) - } - AppendableResponsePart::InputRequest(value) => ResponsePart::InputRequest(value.clone()), - AppendableResponsePart::Unknown(value) => ResponsePart::Unknown(value.clone()), - } -} - -fn is_error_response_part(part: &AppendableResponsePart) -> bool { - matches!( - part, - AppendableResponsePart::Unknown(serde_json::Value::Object(value)) - if value.get("kind").and_then(serde_json::Value::as_str) == Some("error") - ) -} - // ─── Root Reducer ───────────────────────────────────────────────────── /// Apply a [`StateAction`] to a [`RootState`] in place. @@ -993,12 +971,10 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu if active.id != a.turn_id { return ReduceOutcome::NoOp; } - if is_error_response_part(&a.part) { + if matches!(a.part, ResponsePart::Error(_)) { return ReduceOutcome::NoOp; } - active - .response_parts - .push(appendable_response_part(&a.part)); + active.response_parts.push(a.part.clone()); ReduceOutcome::Applied } StateAction::ChatTurnComplete(a) => end_turn( diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 1623a14d7..356f0ae24 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -338,8 +338,8 @@ public struct ChatResponsePartAction: Codable, Sendable { public var type: ActionType /// Turn identifier public var turnId: String - /// Non-error response part to append. - public var part: AppendableResponsePart + /// Response part to append; error parts are ignored. + public var part: ResponsePart /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -359,7 +359,7 @@ public struct ChatResponsePartAction: Codable, Sendable { public init( type: ActionType, turnId: String, - part: AppendableResponsePart, + part: ResponsePart, meta: [String: AnyCodable]? = nil ) { self.type = type diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 2e24c5de6..7d7448753 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -5347,55 +5347,6 @@ public enum ChatOrigin: Codable, Sendable { } } -public enum AppendableResponsePart: Codable, Sendable { - case markdown(MarkdownResponsePart) - case contentRef(ResourceResponsePart) - case toolCall(ToolCallResponsePart) - case reasoning(ReasoningResponsePart) - case systemNotification(SystemNotificationResponsePart) - case inputRequest(InputRequestResponsePart) - /// Unknown or future discriminant; the raw payload is preserved - /// and re-encoded verbatim for forward-compatibility. - case unknown(AnyCodable) - - private enum DiscriminantKey: String, CodingKey { - case discriminant = "kind" - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: DiscriminantKey.self) - let discriminant = try container.decode(String.self, forKey: .discriminant) - switch discriminant { - case "markdown": - self = .markdown(try MarkdownResponsePart(from: decoder)) - case "contentRef": - self = .contentRef(try ResourceResponsePart(from: decoder)) - case "toolCall": - self = .toolCall(try ToolCallResponsePart(from: decoder)) - case "reasoning": - self = .reasoning(try ReasoningResponsePart(from: decoder)) - case "systemNotification": - self = .systemNotification(try SystemNotificationResponsePart(from: decoder)) - case "inputRequest": - self = .inputRequest(try InputRequestResponsePart(from: decoder)) - default: - self = .unknown(try AnyCodable(from: decoder)) - } - } - - public func encode(to encoder: Encoder) throws { - switch self { - case .markdown(let value): try value.encode(to: encoder) - case .contentRef(let value): try value.encode(to: encoder) - case .toolCall(let value): try value.encode(to: encoder) - 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 .unknown(let value): try value.encode(to: encoder) - } - } -} - public enum ResponsePart: Codable, Sendable { case markdown(MarkdownResponsePart) case contentRef(ResourceResponsePart) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 331f78436..617d4bf66 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -27,26 +27,6 @@ private func withStatusFlag(_ status: SessionStatus, _ flag: SessionStatus, _ se set ? status.union(flag) : status.subtracting(flag) } -private func responsePart(_ part: AppendableResponsePart) -> ResponsePart { - switch part { - case .markdown(let value): return .markdown(value) - case .contentRef(let value): return .contentRef(value) - case .toolCall(let value): return .toolCall(value) - case .reasoning(let value): return .reasoning(value) - case .systemNotification(let value): return .systemNotification(value) - case .inputRequest(let value): return .inputRequest(value) - case .unknown(let value): return .unknown(value) - } -} - -private func isErrorResponsePart(_ part: AppendableResponsePart) -> Bool { - guard case .unknown(let raw) = part, - let value = raw.value as? [String: Any] else { - return false - } - return value["kind"] as? String == "error" -} - /// Whether an entry blocks on the *user*. /// /// `.toolClientExecution` is work delegated to a client, not a prompt: the call @@ -184,10 +164,10 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { guard var activeTurn = state.activeTurn, activeTurn.id == a.turnId else { return state } - guard !isErrorResponsePart(a.part) else { + if case .error = a.part { return state } - activeTurn.responseParts.append(responsePart(a.part)) + activeTurn.responseParts.append(a.part) var next = state next.activeTurn = activeTurn return next diff --git a/schema/actions.schema.json b/schema/actions.schema.json index a489fdb77..e5afd80af 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -751,8 +751,8 @@ "description": "Turn identifier" }, "part": { - "$ref": "#/$defs/AppendableResponsePart", - "description": "Non-error response part to append." + "$ref": "#/$defs/ResponsePart", + "description": "Response part to append; error parts are ignored." }, "_meta": { "type": "object", @@ -7408,7 +7408,7 @@ ], "description": "An attachment associated with a {@link Message}." }, - "AppendableResponsePart": { + "ResponsePart": { "oneOf": [ { "$ref": "#/$defs/MarkdownResponsePart" @@ -7427,20 +7427,11 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" - } - ], - "description": "A response part that may be appended while a turn is active with\n`chat/responsePart`.\n\nErrors are excluded because they must be appended atomically with the\nterminal `chat/error` transition." - }, - "ResponsePart": { - "oneOf": [ - { - "$ref": "#/$defs/AppendableResponsePart" }, { "$ref": "#/$defs/ErrorResponsePart" } - ], - "description": "Any durable part of a turn's response stream." + ] }, "ToolCallRiskAssessment": { "oneOf": [ diff --git a/schema/commands.schema.json b/schema/commands.schema.json index ae4bb6322..c586c2ff9 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -7147,8 +7147,8 @@ "description": "Turn identifier" }, "part": { - "$ref": "#/$defs/AppendableResponsePart", - "description": "Non-error response part to append." + "$ref": "#/$defs/ResponsePart", + "description": "Response part to append; error parts are ignored." }, "_meta": { "type": "object", @@ -9259,13 +9259,27 @@ "ResponsePart": { "oneOf": [ { - "$ref": "#/$defs/AppendableResponsePart" + "$ref": "#/$defs/MarkdownResponsePart" + }, + { + "$ref": "#/$defs/ResourceResponsePart" + }, + { + "$ref": "#/$defs/ToolCallResponsePart" + }, + { + "$ref": "#/$defs/ReasoningResponsePart" + }, + { + "$ref": "#/$defs/SystemNotificationResponsePart" + }, + { + "$ref": "#/$defs/InputRequestResponsePart" }, { "$ref": "#/$defs/ErrorResponsePart" } - ], - "description": "Any durable part of a turn's response stream." + ] }, "TurnState": { "enum": [ @@ -9430,29 +9444,6 @@ "type": "string", "description": "Discriminant for {@link ResourceChange.type}." }, - "AppendableResponsePart": { - "oneOf": [ - { - "$ref": "#/$defs/MarkdownResponsePart" - }, - { - "$ref": "#/$defs/ResourceResponsePart" - }, - { - "$ref": "#/$defs/ToolCallResponsePart" - }, - { - "$ref": "#/$defs/ReasoningResponsePart" - }, - { - "$ref": "#/$defs/SystemNotificationResponsePart" - }, - { - "$ref": "#/$defs/InputRequestResponsePart" - } - ], - "description": "A response part that may be appended while a turn is active with\n`chat/responsePart`.\n\nErrors are excluded because they must be appended atomically with the\nterminal `chat/error` transition." - }, "PendingMessageKind": { "enum": [ "steering", diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 4f9acfa3a..e91007997 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -6839,13 +6839,27 @@ "ResponsePart": { "oneOf": [ { - "$ref": "#/$defs/AppendableResponsePart" + "$ref": "#/$defs/MarkdownResponsePart" + }, + { + "$ref": "#/$defs/ResourceResponsePart" + }, + { + "$ref": "#/$defs/ToolCallResponsePart" + }, + { + "$ref": "#/$defs/ReasoningResponsePart" + }, + { + "$ref": "#/$defs/SystemNotificationResponsePart" + }, + { + "$ref": "#/$defs/InputRequestResponsePart" }, { "$ref": "#/$defs/ErrorResponsePart" } - ], - "description": "Any durable part of a turn's response stream." + ] }, "TurnState": { "enum": [ @@ -7447,29 +7461,6 @@ ], "description": "Identifies the file or range a {@link ChangesetOperation} should act on.\n\nThe `kind` MUST match one of the operation's declared\n{@link ChangesetOperation.scopes}." }, - "AppendableResponsePart": { - "oneOf": [ - { - "$ref": "#/$defs/MarkdownResponsePart" - }, - { - "$ref": "#/$defs/ResourceResponsePart" - }, - { - "$ref": "#/$defs/ToolCallResponsePart" - }, - { - "$ref": "#/$defs/ReasoningResponsePart" - }, - { - "$ref": "#/$defs/SystemNotificationResponsePart" - }, - { - "$ref": "#/$defs/InputRequestResponsePart" - } - ], - "description": "A response part that may be appended while a turn is active with\n`chat/responsePart`.\n\nErrors are excluded because they must be appended atomically with the\nterminal `chat/error` transition." - }, "ActionOrigin": { "type": "object", "description": "Identifies the client that originally dispatched an action.", @@ -8166,8 +8157,8 @@ "description": "Turn identifier" }, "part": { - "$ref": "#/$defs/AppendableResponsePart", - "description": "Non-error response part to append." + "$ref": "#/$defs/ResponsePart", + "description": "Response part to append; error parts are ignored." }, "_meta": { "type": "object", diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 161ef91de..36a62a436 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -5517,13 +5517,27 @@ "ResponsePart": { "oneOf": [ { - "$ref": "#/$defs/AppendableResponsePart" + "$ref": "#/$defs/MarkdownResponsePart" + }, + { + "$ref": "#/$defs/ResourceResponsePart" + }, + { + "$ref": "#/$defs/ToolCallResponsePart" + }, + { + "$ref": "#/$defs/ReasoningResponsePart" + }, + { + "$ref": "#/$defs/SystemNotificationResponsePart" + }, + { + "$ref": "#/$defs/InputRequestResponsePart" }, { "$ref": "#/$defs/ErrorResponsePart" } - ], - "description": "Any durable part of a turn's response stream." + ] }, "TurnState": { "enum": [ @@ -5737,29 +5751,6 @@ ], "type": "string", "description": "Discriminant for {@link ResourceChange.type}." - }, - "AppendableResponsePart": { - "oneOf": [ - { - "$ref": "#/$defs/MarkdownResponsePart" - }, - { - "$ref": "#/$defs/ResourceResponsePart" - }, - { - "$ref": "#/$defs/ToolCallResponsePart" - }, - { - "$ref": "#/$defs/ReasoningResponsePart" - }, - { - "$ref": "#/$defs/SystemNotificationResponsePart" - }, - { - "$ref": "#/$defs/InputRequestResponsePart" - } - ], - "description": "A response part that may be appended while a turn is active with\n`chat/responsePart`.\n\nErrors are excluded because they must be appended atomically with the\nterminal `chat/error` transition." } } } diff --git a/schema/state.schema.json b/schema/state.schema.json index 46f02479e..3beee3027 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -5204,7 +5204,7 @@ ], "description": "An attachment associated with a {@link Message}." }, - "AppendableResponsePart": { + "ResponsePart": { "oneOf": [ { "$ref": "#/$defs/MarkdownResponsePart" @@ -5223,20 +5223,11 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" - } - ], - "description": "A response part that may be appended while a turn is active with\n`chat/responsePart`.\n\nErrors are excluded because they must be appended atomically with the\nterminal `chat/error` transition." - }, - "ResponsePart": { - "oneOf": [ - { - "$ref": "#/$defs/AppendableResponsePart" }, { "$ref": "#/$defs/ErrorResponsePart" } - ], - "description": "Any durable part of a turn's response stream." + ] }, "ToolCallRiskAssessment": { "oneOf": [ diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 48456a411..cf698be96 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -832,10 +832,10 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'ResourceChange' }, ]; -const APPENDABLE_RESPONSE_PART_UNION: UnionConfig = { - name: 'AppendableResponsePart', +const RESPONSE_PART_UNION: UnionConfig = { + name: 'ResponsePart', discriminantField: 'kind', - doc: 'AppendableResponsePart is a non-error part that may be appended while a turn is active.', + doc: 'ResponsePart is a single part of a response stream (text, tool call, reasoning, content reference).', variants: [ { variantName: 'Markdown', innerType: 'MarkdownResponsePart', wireValue: 'markdown' }, { variantName: 'ContentRef', innerType: 'ResourceResponsePart', wireValue: 'contentRef' }, @@ -843,16 +843,6 @@ const APPENDABLE_RESPONSE_PART_UNION: UnionConfig = { { variantName: 'Reasoning', innerType: 'ReasoningResponsePart', wireValue: 'reasoning' }, { variantName: 'SystemNotification', innerType: 'SystemNotificationResponsePart', wireValue: 'systemNotification' }, { variantName: 'InputRequest', innerType: 'InputRequestResponsePart', wireValue: 'inputRequest' }, - ], - unknown: true, -}; - -const RESPONSE_PART_UNION: UnionConfig = { - name: 'ResponsePart', - discriminantField: 'kind', - doc: 'ResponsePart is a single part of a response stream (text, tool call, reasoning, content reference).', - variants: [ - ...APPENDABLE_RESPONSE_PART_UNION.variants, { variantName: 'Error', innerType: 'ErrorResponsePart', wireValue: 'error' }, ], unknown: true, @@ -1331,8 +1321,6 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push('// ─── Discriminated Unions ─────────────────────────────────────────────\n'); - lines.push(generateDiscriminatedUnion(APPENDABLE_RESPONSE_PART_UNION)); - lines.push(''); lines.push(generateDiscriminatedUnion(RESPONSE_PART_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(TOOL_CALL_STATE_UNION)); @@ -2071,7 +2059,6 @@ function checkExhaustiveness(project: Project): void { 'StateAction', 'ActionEnvelope', 'ActionOrigin', - 'AppendableResponsePart', 'ResponsePart', 'ToolResultContent', 'SessionToolCallApprovedAction', diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index ba72d6d64..bdd1e22c3 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -961,8 +961,8 @@ const STATE_STRUCTS = [ 'ResourceWatchState', 'ResourceChange', ]; -const APPENDABLE_RESPONSE_PART_UNION: UnionConfig = { - name: 'AppendableResponsePart', +const RESPONSE_PART_UNION: UnionConfig = { + name: 'ResponsePart', discriminantField: 'kind', variants: [ { caseName: 'Markdown', structName: 'MarkdownResponsePart', discriminantValue: 'markdown' }, @@ -971,15 +971,6 @@ const APPENDABLE_RESPONSE_PART_UNION: UnionConfig = { { caseName: 'Reasoning', structName: 'ReasoningResponsePart', discriminantValue: 'reasoning' }, { caseName: 'SystemNotification', structName: 'SystemNotificationResponsePart', discriminantValue: 'systemNotification' }, { caseName: 'InputRequest', structName: 'InputRequestResponsePart', discriminantValue: 'inputRequest' }, - ], - unknown: true, -}; - -const RESPONSE_PART_UNION: UnionConfig = { - name: 'ResponsePart', - discriminantField: 'kind', - variants: [ - ...APPENDABLE_RESPONSE_PART_UNION.variants, { caseName: 'Error', structName: 'ErrorResponsePart', discriminantValue: 'error' }, ], unknown: true, @@ -1276,8 +1267,6 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateChatOriginKotlin()); lines.push(''); - lines.push(generateDiscriminatedUnion(APPENDABLE_RESPONSE_PART_UNION)); - lines.push(''); lines.push(generateDiscriminatedUnion(RESPONSE_PART_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(TOOL_CALL_STATE_UNION)); @@ -2094,7 +2083,6 @@ function checkExhaustiveness(project: Project): void { 'StateAction', // StateAction enum in generateActionsFile() 'ActionEnvelope', // generateDataClassFromInterface() call in generateActionsFile() 'ActionOrigin', // generateDataClassFromInterface() call in generateActionsFile() - 'AppendableResponsePart', // APPENDABLE_RESPONSE_PART_UNION discriminated union 'ResponsePart', // RESPONSE_PART_UNION discriminated union 'ToolResultContent', // generateToolResultContentUnion() 'SessionToolCallApprovedAction', // merged into SessionToolCallConfirmedAction diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 73c3edb60..3877a05b2 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -821,10 +821,10 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'ResourceChange' }, ]; -const APPENDABLE_RESPONSE_PART_UNION: UnionConfig = { - name: 'AppendableResponsePart', +const RESPONSE_PART_UNION: UnionConfig = { + name: 'ResponsePart', discriminantField: 'kind', - doc: 'A non-error part that may be appended while a turn is active.', + doc: 'A single part of a response stream (text, tool call, reasoning, content reference).', variants: [ { variantName: 'Markdown', innerType: 'MarkdownResponsePart', wireValue: 'markdown' }, { variantName: 'ContentRef', innerType: 'ResourceResponsePart', wireValue: 'contentRef' }, @@ -832,16 +832,6 @@ const APPENDABLE_RESPONSE_PART_UNION: UnionConfig = { { variantName: 'Reasoning', innerType: 'ReasoningResponsePart', wireValue: 'reasoning' }, { variantName: 'SystemNotification', innerType: 'SystemNotificationResponsePart', wireValue: 'systemNotification' }, { variantName: 'InputRequest', innerType: 'InputRequestResponsePart', wireValue: 'inputRequest' }, - ], - unknown: true, -}; - -const RESPONSE_PART_UNION: UnionConfig = { - name: 'ResponsePart', - discriminantField: 'kind', - doc: 'A single part of a response stream (text, tool call, reasoning, content reference).', - variants: [ - ...APPENDABLE_RESPONSE_PART_UNION.variants, { variantName: 'Error', innerType: 'ErrorResponsePart', wireValue: 'error' }, ], unknown: true, @@ -1175,8 +1165,6 @@ function generateStateFile(project: Project): string { lines.push('// ─── Discriminated Unions ─────────────────────────────────────────────\n'); lines.push(generateChatOrigin()); lines.push(''); - lines.push(generateDiscriminatedUnion(APPENDABLE_RESPONSE_PART_UNION)); - lines.push(''); lines.push(generateDiscriminatedUnion(RESPONSE_PART_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(TOOL_CALL_STATE_UNION)); @@ -1390,7 +1378,7 @@ impl Serialize for ChatErrorAction { function generateActionsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, AppendableResponsePart, 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('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 @@ -1953,7 +1941,6 @@ function checkExhaustiveness(project: Project): void { 'StateAction', 'ActionEnvelope', 'ActionOrigin', - 'AppendableResponsePart', 'ResponsePart', 'ToolResultContent', 'SessionToolCallApprovedAction', diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 7c05f10fc..fab16083f 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -670,20 +670,6 @@ const STATE_STRUCTS = [ 'ResourceWatchState', 'ResourceChange', ]; -const APPENDABLE_RESPONSE_PART_UNION: UnionConfig = { - name: 'AppendableResponsePart', - discriminantField: 'kind', - allowUnknown: true, - variants: [ - { caseName: 'markdown', structName: 'MarkdownResponsePart', discriminantValue: 'markdown' }, - { caseName: 'contentRef', structName: 'ResourceResponsePart', discriminantValue: 'contentRef' }, - { caseName: 'toolCall', structName: 'ToolCallResponsePart', discriminantValue: 'toolCall' }, - { caseName: 'reasoning', structName: 'ReasoningResponsePart', discriminantValue: 'reasoning' }, - { caseName: 'systemNotification', structName: 'SystemNotificationResponsePart', discriminantValue: 'systemNotification' }, - { caseName: 'inputRequest', structName: 'InputRequestResponsePart', discriminantValue: 'inputRequest' }, - ], -}; - const RESPONSE_PART_UNION: UnionConfig = { name: 'ResponsePart', discriminantField: 'kind', @@ -693,7 +679,12 @@ const RESPONSE_PART_UNION: UnionConfig = { // other parts (by id) still work correctly. Mirrors .NET allowUnknown. allowUnknown: true, variants: [ - ...APPENDABLE_RESPONSE_PART_UNION.variants, + { caseName: 'markdown', structName: 'MarkdownResponsePart', discriminantValue: 'markdown' }, + { caseName: 'contentRef', structName: 'ResourceResponsePart', discriminantValue: 'contentRef' }, + { caseName: 'toolCall', structName: 'ToolCallResponsePart', discriminantValue: 'toolCall' }, + { caseName: 'reasoning', structName: 'ReasoningResponsePart', discriminantValue: 'reasoning' }, + { caseName: 'systemNotification', structName: 'SystemNotificationResponsePart', discriminantValue: 'systemNotification' }, + { caseName: 'inputRequest', structName: 'InputRequestResponsePart', discriminantValue: 'inputRequest' }, { caseName: 'error', structName: 'ErrorResponsePart', discriminantValue: 'error' }, ], }; @@ -1169,8 +1160,6 @@ function generateStateFile(project: Project): string { lines.push('// MARK: - Discriminated Unions\n'); lines.push(generateChatOriginSwift()); lines.push(''); - lines.push(generateDiscriminatedUnion(APPENDABLE_RESPONSE_PART_UNION)); - lines.push(''); lines.push(generateDiscriminatedUnion(RESPONSE_PART_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(TOOL_CALL_STATE_UNION)); @@ -2112,7 +2101,6 @@ function checkExhaustiveness(project: Project): void { 'StateAction', // StateAction enum in generateActionsFile() 'ActionEnvelope', // generateStructFromInterface() call in generateActionsFile() 'ActionOrigin', // generateStructFromInterface() call in generateActionsFile() - 'AppendableResponsePart', // APPENDABLE_RESPONSE_PART_UNION discriminated union 'ResponsePart', // RESPONSE_PART_UNION discriminated union 'ToolResultContent', // TOOL_RESULT_CONTENT_UNION discriminated union 'SessionToolCallApprovedAction', // merged into SessionToolCallConfirmedAction diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index e978781a2..07bfb9468 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -9,7 +9,7 @@ import type { StringOrMarkdown, FileEdit, UsageInfo, URI } from '../common/state import type { McpAuthRequirement } from '../channels-session/state.js'; import type { Message, - AppendableResponsePart, + ResponsePart, ToolCallResult, ToolResultContent, ChatInputAnswer, @@ -129,8 +129,8 @@ export interface ChatResponsePartAction { type: ActionType.ChatResponsePart; /** Turn identifier */ turnId: string; - /** Non-error response part to append. */ - part: AppendableResponsePart; + /** Response part to append; error parts are ignored. */ + part: ResponsePart; /** * Additional provider-specific metadata for this action. * diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index 322966b3d..de907b8b7 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -916,28 +916,16 @@ export interface ReasoningResponsePart { } /** - * A response part that may be appended while a turn is active with - * `chat/responsePart`. - * - * Errors are excluded because they must be appended atomically with the - * terminal `chat/error` transition. - * * @category Response Parts */ -export type AppendableResponsePart = +export type ResponsePart = | MarkdownResponsePart | ResourceResponsePart | ToolCallResponsePart | ReasoningResponsePart | SystemNotificationResponsePart - | InputRequestResponsePart; - -/** - * Any durable part of a turn's response stream. - * - * @category Response Parts - */ -export type ResponsePart = AppendableResponsePart | ErrorResponsePart; + | InputRequestResponsePart + | ErrorResponsePart; /** * A live or resolved input request (elicitation) in the turn response stream.