diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index 340ffcf07..b120dce1e 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -59,6 +59,8 @@ public enum ActionType ChatTurnCancelled, [WireValue("chat/error")] ChatError, + [WireValue("chat/turnResume")] + ChatTurnResume, [WireValue("chat/activityChanged")] ChatActivityChanged, [WireValue("chat/workingDirectorySet")] @@ -1162,7 +1164,10 @@ public sealed record ChatDeltaAction public Dictionary? Meta { get; init; } } -/// Structured content appended to the response. +/// 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. public sealed record ChatResponsePartAction { public ActionType Type { get; init; } @@ -1170,7 +1175,7 @@ public sealed record ChatResponsePartAction /// Turn identifier public required string TurnId { get; init; } - /// Response part (markdown or content ref) + /// Response part to append; error parts are ignored. public required ResponsePart Part { get; init; } /// Additional provider-specific metadata for this action. @@ -1622,8 +1627,9 @@ public sealed record ChatErrorAction /// data. public long Duration { get; init; } - /// Error details - public required ErrorInfo Error { get; init; } + /// Error part to append to the response stream before finalizing the turn. + /// Its optional `resumable` flag indicates whether the turn can be resumed. + public required ErrorResponsePart Part { get; init; } /// Additional provider-specific metadata for this action. /// @@ -1637,6 +1643,20 @@ public sealed record ChatErrorAction public Dictionary? Meta { get; init; } } +/// Resumes the latest errored turn without adding another message. +/// +/// The turn MUST be the latest turn, its state MUST be `error`, and its final +/// response part MUST be a resumable error. The reducer reopens the same turn +/// with its existing message, response parts, and usage intact. The host then +/// resumes the provider's execution for that turn. +public sealed record ChatTurnResumeAction +{ + public ActionType Type { get; init; } + + /// Identifier of the errored turn. + public required string TurnId { get; init; } +} + /// The activity description of this chat changed. /// /// Dispatched by the server to indicate what the chat is currently doing @@ -2596,6 +2616,7 @@ public StateActionConverter() ["chat/turnComplete"] = typeof(ChatTurnCompleteAction), ["chat/turnCancelled"] = typeof(ChatTurnCancelledAction), ["chat/error"] = typeof(ChatErrorAction), + ["chat/turnResume"] = typeof(ChatTurnResumeAction), ["chat/activityChanged"] = typeof(ChatActivityChangedAction), ["chat/workingDirectorySet"] = typeof(ChatWorkingDirectorySetAction), ["chat/workingDirectoryRemoved"] = typeof(ChatWorkingDirectoryRemovedAction), diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs index 6dd6f13a8..545e9b55d 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs @@ -149,6 +149,7 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(ChatTruncatedAction))] [JsonSerializable(typeof(ChatTurnCancelledAction))] [JsonSerializable(typeof(ChatTurnCompleteAction))] +[JsonSerializable(typeof(ChatTurnResumeAction))] [JsonSerializable(typeof(ChatTurnsLoadedAction))] [JsonSerializable(typeof(ChatTurnStartedAction))] [JsonSerializable(typeof(ChatUsageAction))] @@ -193,6 +194,7 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(DisposeSessionParams))] [JsonSerializable(typeof(DisposeTerminalParams))] [JsonSerializable(typeof(ErrorInfo))] +[JsonSerializable(typeof(ErrorResponsePart))] [JsonSerializable(typeof(FetchAutomationRunsParams))] [JsonSerializable(typeof(FetchAutomationRunsResult))] [JsonSerializable(typeof(FetchTurnsParams))] diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index 532cdb4f0..6f33169f3 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -270,6 +270,8 @@ public enum ResponsePartKind SystemNotification, [WireValue("inputRequest")] InputRequest, + [WireValue("error")] + Error, } /// Status of a tool call in the lifecycle state machine. @@ -2061,10 +2063,6 @@ public sealed class Turn /// How the turn ended public TurnState State { get; set; } - - /// Error details if state is `'error'` - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public ErrorInfo? Error { get; set; } } /// An in-progress turn — the assistant is actively streaming. @@ -2611,6 +2609,28 @@ public sealed record InputRequestResponsePart public ChatInputResponseKind? Response { get; init; } } +/// An error encountered while processing a turn. +/// +/// This is the detailed source of truth for the error. {@link Turn.state} +/// remains {@link TurnState.Error} while the turn is stopped at this error so +/// clients can detect the terminal state without inspecting response parts. +/// +/// When {@link resumable} is `true`, a client may dispatch `chat/turnResume` +/// while this is the latest turn and its state is {@link TurnState.Error}. +/// Clients decide whether and how to present that affordance. +public sealed record ErrorResponsePart +{ + /// Discriminant + public ResponsePartKind Kind { get; init; } + + /// Error details. + public required ErrorInfo Error { get; init; } + + /// Whether the host can resume the turn from this error. Only `true` enables resume. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Resumable { get; init; } +} + /// Tool execution result details, available after execution completes. public sealed record ToolCallResult { @@ -5508,6 +5528,7 @@ public ResponsePartConverter() ["reasoning"] = typeof(ReasoningResponsePart), ["systemNotification"] = typeof(SystemNotificationResponsePart), ["inputRequest"] = typeof(InputRequestResponsePart), + ["error"] = typeof(ErrorResponsePart), }, allowUnknown: true) { diff --git a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs index 2bcda2d59..bfaa9f2c3 100644 --- a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs @@ -151,6 +151,9 @@ public static bool TryGetActionType(object action, out ActionType actionType) case ChatTurnCompleteAction value: actionType = value.Type; return true; + case ChatTurnResumeAction value: + actionType = value.Type; + return true; case ChatTurnsLoadedAction value: actionType = value.Type; return true; @@ -410,6 +413,7 @@ public static string GetWireName(ActionType actionType) => ActionType.ChatTruncated => "chat/truncated", ActionType.ChatTurnCancelled => "chat/turnCancelled", ActionType.ChatTurnComplete => "chat/turnComplete", + ActionType.ChatTurnResume => "chat/turnResume", ActionType.ChatTurnsLoaded => "chat/turnsLoaded", ActionType.ChatTurnStarted => "chat/turnStarted", ActionType.ChatUsage => "chat/usage", diff --git a/clients/dotnet/src/AgentHostProtocol/Reducers.cs b/clients/dotnet/src/AgentHostProtocol/Reducers.cs index d67ab415b..8ad227254 100644 --- a/clients/dotnet/src/AgentHostProtocol/Reducers.cs +++ b/clients/dotnet/src/AgentHostProtocol/Reducers.cs @@ -287,7 +287,7 @@ private static ReduceOutcome EndTurn( TurnState turnState, long duration, SessionStatus? terminalStatus, - ErrorInfo? errInfo) + ErrorResponsePart? errorPart) { if (state.ActiveTurn is null || state.ActiveTurn.Id != turnId) { @@ -333,6 +333,10 @@ private static ReduceOutcome EndTurn( ToolCall = new ToolCallState(cancelled), })); } + if (errorPart is not null) + { + parts.Add(new ResponsePart(errorPart)); + } var turn = new Turn { @@ -345,7 +349,6 @@ private static ReduceOutcome EndTurn( ResponseParts = parts, Usage = active.Usage, State = turnState, - Error = errInfo, }; state.Turns.Add(turn); @@ -913,6 +916,10 @@ public static ReduceOutcome ApplyToChat(ChatState state, StateAction action) { return ReduceOutcome.NoOp; } + if (a.Part.Value is ErrorResponsePart) + { + return ReduceOutcome.NoOp; + } state.ActiveTurn.ResponseParts.Add(a.Part); return ReduceOutcome.Applied; @@ -921,7 +928,33 @@ public static ReduceOutcome ApplyToChat(ChatState state, StateAction action) case ChatTurnCancelledAction a: return EndTurn(state, a.TurnId, TurnState.Cancelled, a.Duration, null, null); case ChatErrorAction a: - return EndTurn(state, a.TurnId, TurnState.Error, a.Duration, SessionStatus.Error, a.Error); + return EndTurn(state, a.TurnId, TurnState.Error, a.Duration, SessionStatus.Error, a.Part); + case ChatTurnResumeAction a: + if (state.ActiveTurn is not null || state.Turns.Count == 0) + { + return ReduceOutcome.NoOp; + } + + Turn resumableTurn = state.Turns[state.Turns.Count - 1]; + if (resumableTurn.Id != a.TurnId + || resumableTurn.State != TurnState.Error + || resumableTurn.ResponseParts.Count == 0 + || resumableTurn.ResponseParts[resumableTurn.ResponseParts.Count - 1].Value is not ErrorResponsePart { Resumable: true }) + { + return ReduceOutcome.NoOp; + } + + state.Turns.RemoveAt(state.Turns.Count - 1); + state.ActiveTurn = new ActiveTurn + { + Id = resumableTurn.Id, + StartedAt = resumableTurn.StartedAt ?? state.ModifiedAt, + Message = resumableTurn.Message, + ResponseParts = resumableTurn.ResponseParts, + Usage = resumableTurn.Usage, + }; + state.Status = WithStatusFlag(ChatSummaryStatus(state, null), SessionStatus.IsRead, false); + return ReduceOutcome.Applied; case ChatActivityChangedAction a: state.Activity = a.Activity; return ReduceOutcome.Applied; @@ -2415,6 +2448,7 @@ public static ReduceOutcome ApplyToAutomationRun( "chat/toolCallResultConfirmed", "chat/toolCallContentChanged", "chat/turnCancelled", + "chat/turnResume", "chat/pendingMessageSet", "chat/pendingMessageRemoved", "chat/queuedMessagesReordered", diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 5f8639076..445cdd355 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -170,6 +170,14 @@ func hasOpenInputRequest(state *ahptypes.ChatState) bool { return false } +func hasResumableError(turn *ahptypes.Turn) bool { + if len(turn.ResponseParts) == 0 { + return false + } + part, ok := turn.ResponseParts[len(turn.ResponseParts)-1].Value.(*ahptypes.ErrorResponsePart) + return ok && part.Resumable != nil && *part.Resumable +} + func summaryStatus(state *ahptypes.ChatState, terminal *ahptypes.SessionStatus) ahptypes.SessionStatus { var activity ahptypes.SessionStatus switch { @@ -191,7 +199,7 @@ func refreshSummaryStatus(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 } @@ -229,6 +237,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. @@ -244,7 +255,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) @@ -436,6 +446,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 @@ -509,6 +520,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: @@ -516,9 +530,32 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R case *ahptypes.ChatTurnCancelledAction: return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateCancelled, nil, nil) case *ahptypes.ChatErrorAction: - errCopy := a.Error errStatus := ahptypes.SessionStatusError - return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateError, &errStatus, &errCopy) + return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateError, &errStatus, &a.Part) + case *ahptypes.ChatTurnResumeAction: + if state.ActiveTurn != nil || len(state.Turns) == 0 { + return ReduceOutcomeNoOp + } + turnIndex := len(state.Turns) - 1 + turn := state.Turns[turnIndex] + if turn.Id != a.TurnId || turn.State != ahptypes.TurnStateError || !hasResumableError(&turn) { + return ReduceOutcomeNoOp + } + + startedAt := state.ModifiedAt + if turn.StartedAt != nil { + startedAt = *turn.StartedAt + } + state.Turns = state.Turns[:turnIndex] + state.ActiveTurn = &ahptypes.ActiveTurn{ + Id: turn.Id, + StartedAt: startedAt, + Message: turn.Message, + ResponseParts: turn.ResponseParts, + Usage: turn.Usage, + } + state.Status = withStatusFlag(summaryStatus(state, nil), ahptypes.SessionStatusIsRead, false) + 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 6f87b4381..d5c8b412a 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -42,6 +42,7 @@ const ( ActionTypeChatTurnComplete ActionType = "chat/turnComplete" ActionTypeChatTurnCancelled ActionType = "chat/turnCancelled" ActionTypeChatError ActionType = "chat/error" + ActionTypeChatTurnResume ActionType = "chat/turnResume" ActionTypeChatActivityChanged ActionType = "chat/activityChanged" ActionTypeChatWorkingDirectorySet ActionType = "chat/workingDirectorySet" ActionTypeChatWorkingDirectoryRemoved ActionType = "chat/workingDirectoryRemoved" @@ -267,11 +268,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. // @@ -599,8 +603,9 @@ type ChatErrorAction struct { // client clocks may differ — and MUST treat it as opaque, producer-supplied // data. Duration int64 `json:"duration"` - // Error details - Error ErrorInfo `json:"error"` + // Error part to append to the response stream before finalizing the turn. + // Its optional `resumable` flag indicates whether the turn can be resumed. + Part ErrorResponsePart `json:"part"` // Additional provider-specific metadata for this action. // // Clients MAY look for well-known keys here to provide enhanced UI, and @@ -611,6 +616,18 @@ type ChatErrorAction struct { Meta map[string]json.RawMessage `json:"_meta,omitempty"` } +// Resumes the latest errored turn without adding another message. +// +// The turn MUST be the latest turn, its state MUST be `error`, and its final +// response part MUST be a resumable error. The reducer reopens the same turn +// with its existing message, response parts, and usage intact. The host then +// resumes the provider's execution for that turn. +type ChatTurnResumeAction struct { + Type ActionType `json:"type"` + // Identifier of the errored turn. + TurnId string `json:"turnId"` +} + // The activity description of this chat changed. // // Dispatched by the server to indicate what the chat is currently doing @@ -1669,6 +1686,7 @@ func (*ChatToolCallAuthResolvedAction) isStateAction() {} func (*ChatTurnCompleteAction) isStateAction() {} func (*ChatTurnCancelledAction) isStateAction() {} func (*ChatErrorAction) isStateAction() {} +func (*ChatTurnResumeAction) isStateAction() {} func (*ChatActivityChangedAction) isStateAction() {} func (*SessionTitleChangedAction) isStateAction() {} func (*ChatUsageAction) isStateAction() {} @@ -1899,6 +1917,12 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "chat/turnResume": + var value ChatTurnResumeAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value case "chat/activityChanged": var value ChatActivityChangedAction if err := json.Unmarshal(data, &value); err != nil { diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 1421cacab..399c12475 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -218,6 +218,7 @@ const ( ResponsePartKindReasoning ResponsePartKind = "reasoning" ResponsePartKindSystemNotification ResponsePartKind = "systemNotification" ResponsePartKindInputRequest ResponsePartKind = "inputRequest" + ResponsePartKindError ResponsePartKind = "error" ) // Status of a tool call in the lifecycle state machine. @@ -1403,8 +1404,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. @@ -1977,6 +1976,24 @@ type InputRequestResponsePart struct { Response *ChatInputResponseKind `json:"response,omitempty"` } +// An error encountered while processing a turn. +// +// This is the detailed source of truth for the error. {@link Turn.state} +// remains {@link TurnState.Error} while the turn is stopped at this error so +// clients can detect the terminal state without inspecting response parts. +// +// When {@link resumable} is `true`, a client may dispatch `chat/turnResume` +// while this is the latest turn and its state is {@link TurnState.Error}. +// Clients decide whether and how to present that affordance. +type ErrorResponsePart struct { + // Discriminant + Kind ResponsePartKind `json:"kind"` + // Error details. + Error ErrorInfo `json:"error"` + // Whether the host can resume the turn from this error. Only `true` enables resume. + Resumable *bool `json:"resumable,omitempty"` +} + // Tool execution result details, available after execution completes. type ToolCallResult struct { // Whether the tool succeeded @@ -4199,6 +4216,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 { @@ -4250,6 +4268,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 d993a1455..435e10b1b 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -399,7 +399,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 @@ -443,6 +443,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. @@ -451,10 +456,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( @@ -906,7 +910,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( @@ -922,7 +926,39 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when endTurn(state, action.value.turnId, action.value.duration, TurnState.CANCELLED) is StateActionChatError -> - endTurn(state, action.value.turnId, action.value.duration, TurnState.ERROR, SessionStatus.ERROR, action.value.error) + endTurn(state, action.value.turnId, action.value.duration, TurnState.ERROR, SessionStatus.ERROR, action.value.part) + + is StateActionChatTurnResume -> { + val a = action.value + if (state.activeTurn != null || state.turns.isEmpty()) { + state + } else { + val turnIndex = state.turns.lastIndex + val turn = state.turns[turnIndex] + if (turn.id != a.turnId || turn.state != TurnState.ERROR) { + state + } else { + val errorPart = turn.responseParts.lastOrNull() as? ResponsePartError + if (errorPart?.value?.resumable != true) { + state + } else { + val withTurn = state.copy( + turns = state.turns.dropLast(1), + activeTurn = ActiveTurn( + id = turn.id, + startedAt = turn.startedAt ?: state.modifiedAt, + message = turn.message, + responseParts = turn.responseParts, + usage = turn.usage, + ), + ) + withTurn.copy( + status = withStatusFlag(chatSummaryStatus(withTurn), SessionStatus.IS_READ, false), + ) + } + } + } + } 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 ab6f6bcbc..84ae20747 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 @@ -52,6 +52,7 @@ value class ActionType(val rawValue: String) { val CHAT_TURN_COMPLETE: ActionType = ActionType("chat/turnComplete") val CHAT_TURN_CANCELLED: ActionType = ActionType("chat/turnCancelled") val CHAT_ERROR: ActionType = ActionType("chat/error") + val CHAT_TURN_RESUME: ActionType = ActionType("chat/turnResume") val CHAT_ACTIVITY_CHANGED: ActionType = ActionType("chat/activityChanged") val CHAT_WORKING_DIRECTORY_SET: ActionType = ActionType("chat/workingDirectorySet") val CHAT_WORKING_DIRECTORY_REMOVED: ActionType = ActionType("chat/workingDirectoryRemoved") @@ -302,7 +303,7 @@ data class ChatResponsePartAction( */ val turnId: String, /** - * Response part (markdown or content ref) + * Response part to append; error parts are ignored. */ val part: ResponsePart, /** @@ -690,9 +691,10 @@ data class ChatErrorAction( */ val duration: Long, /** - * Error details + * Error part to append to the response stream before finalizing the turn. + * Its optional `resumable` flag indicates whether the turn can be resumed. */ - val error: ErrorInfo, + val part: ErrorResponsePart, /** * Additional provider-specific metadata for this action. * @@ -706,6 +708,15 @@ data class ChatErrorAction( val meta: Map? = null ) +@Serializable +data class ChatTurnResumeAction( + val type: ActionType, + /** + * Identifier of the errored turn. + */ + val turnId: String +) + @Serializable data class ChatActivityChangedAction( val type: ActionType, @@ -1591,6 +1602,7 @@ sealed interface StateAction @JvmInline value class StateActionChatTurnComplete(val value: ChatTurnCompleteAction) : StateAction @JvmInline value class StateActionChatTurnCancelled(val value: ChatTurnCancelledAction) : StateAction @JvmInline value class StateActionChatError(val value: ChatErrorAction) : StateAction +@JvmInline value class StateActionChatTurnResume(val value: ChatTurnResumeAction) : StateAction @JvmInline value class StateActionChatActivityChanged(val value: ChatActivityChangedAction) : StateAction @JvmInline value class StateActionSessionTitleChanged(val value: SessionTitleChangedAction) : StateAction @JvmInline value class StateActionChatUsage(val value: ChatUsageAction) : StateAction @@ -1701,6 +1713,7 @@ internal object StateActionSerializer : KSerializer { "chat/turnComplete" -> StateActionChatTurnComplete(input.json.decodeFromJsonElement(ChatTurnCompleteAction.serializer(), element)) "chat/turnCancelled" -> StateActionChatTurnCancelled(input.json.decodeFromJsonElement(ChatTurnCancelledAction.serializer(), element)) "chat/error" -> StateActionChatError(input.json.decodeFromJsonElement(ChatErrorAction.serializer(), element)) + "chat/turnResume" -> StateActionChatTurnResume(input.json.decodeFromJsonElement(ChatTurnResumeAction.serializer(), element)) "chat/activityChanged" -> StateActionChatActivityChanged(input.json.decodeFromJsonElement(ChatActivityChangedAction.serializer(), element)) "session/titleChanged" -> StateActionSessionTitleChanged(input.json.decodeFromJsonElement(SessionTitleChangedAction.serializer(), element)) "chat/usage" -> StateActionChatUsage(input.json.decodeFromJsonElement(ChatUsageAction.serializer(), element)) @@ -1804,6 +1817,7 @@ internal object StateActionSerializer : KSerializer { is StateActionChatTurnComplete -> output.json.encodeToJsonElement(ChatTurnCompleteAction.serializer(), value.value) is StateActionChatTurnCancelled -> output.json.encodeToJsonElement(ChatTurnCancelledAction.serializer(), value.value) is StateActionChatError -> output.json.encodeToJsonElement(ChatErrorAction.serializer(), value.value) + is StateActionChatTurnResume -> output.json.encodeToJsonElement(ChatTurnResumeAction.serializer(), value.value) is StateActionChatActivityChanged -> output.json.encodeToJsonElement(ChatActivityChangedAction.serializer(), value.value) is StateActionSessionTitleChanged -> output.json.encodeToJsonElement(SessionTitleChangedAction.serializer(), value.value) is StateActionChatUsage -> output.json.encodeToJsonElement(ChatUsageAction.serializer(), value.value) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt index 81892ef02..0a3f22707 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 @@ -471,6 +471,7 @@ value class ResponsePartKind(val rawValue: String) { val REASONING: ResponsePartKind = ResponsePartKind("reasoning") val SYSTEM_NOTIFICATION: ResponsePartKind = ResponsePartKind("systemNotification") val INPUT_REQUEST: ResponsePartKind = ResponsePartKind("inputRequest") + val ERROR: ResponsePartKind = ResponsePartKind("error") } } @@ -2147,11 +2148,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 @@ -2928,6 +2925,22 @@ data class InputRequestResponsePart( val response: ChatInputResponseKind? = null ) +@Serializable +data class ErrorResponsePart( + /** + * Discriminant + */ + val kind: ResponsePartKind, + /** + * Error details. + */ + val error: ErrorInfo, + /** + * Whether the host can resume the turn from this error. Only `true` enables resume. + */ + val resumable: Boolean? = null +) + @Serializable data class ToolCallResult( /** @@ -5784,6 +5797,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. * @@ -5814,6 +5829,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) } } @@ -5828,6 +5844,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 06f211a2c..4c0b67854 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -18,11 +18,11 @@ use crate::state::{ Changeset, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ChatSummary, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, - McpAuthRequirement, McpServerState, Message, ModelSelection, PendingMessageKind, ResponsePart, - SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, - TextRange, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributor, - ToolCallResult, ToolCallRiskAssessment, ToolDefinition, ToolInput, ToolResultContent, Turn, - UsageInfo, + ErrorResponsePart, McpAuthRequirement, McpServerState, Message, ModelSelection, + PendingMessageKind, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, + TerminalClaim, TerminalInfo, TextRange, ToolCallCancellationReason, ToolCallConfirmationReason, + ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolDefinition, ToolInput, + ToolResultContent, Turn, UsageInfo, }; // ─── ActionType ────────────────────────────────────────────────────── @@ -53,6 +53,7 @@ pub enum ActionType { ChatTurnComplete, ChatTurnCancelled, ChatError, + ChatTurnResume, ChatActivityChanged, ChatWorkingDirectorySet, ChatWorkingDirectoryRemoved, @@ -166,6 +167,7 @@ impl serde::Serialize for ActionType { Self::ChatTurnComplete => serializer.serialize_str("chat/turnComplete"), Self::ChatTurnCancelled => serializer.serialize_str("chat/turnCancelled"), Self::ChatError => serializer.serialize_str("chat/error"), + Self::ChatTurnResume => serializer.serialize_str("chat/turnResume"), Self::ChatActivityChanged => serializer.serialize_str("chat/activityChanged"), Self::ChatWorkingDirectorySet => serializer.serialize_str("chat/workingDirectorySet"), Self::ChatWorkingDirectoryRemoved => { @@ -325,6 +327,7 @@ impl<'de> serde::Deserialize<'de> for ActionType { "chat/turnComplete" => Self::ChatTurnComplete, "chat/turnCancelled" => Self::ChatTurnCancelled, "chat/error" => Self::ChatError, + "chat/turnResume" => Self::ChatTurnResume, "chat/activityChanged" => Self::ChatActivityChanged, "chat/workingDirectorySet" => Self::ChatWorkingDirectorySet, "chat/workingDirectoryRemoved" => Self::ChatWorkingDirectoryRemoved, @@ -579,12 +582,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. /// @@ -953,7 +959,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 @@ -963,8 +969,9 @@ pub struct ChatErrorAction { /// client clocks may differ — and MUST treat it as opaque, producer-supplied /// data. pub duration: i64, - /// Error details - pub error: ErrorInfo, + /// Error part to append to the response stream before finalizing the turn. + /// Its optional `resumable` flag indicates whether the turn can be resumed. + pub part: ErrorResponsePart, /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -976,6 +983,54 @@ pub struct ChatErrorAction { pub meta: Option, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ChatErrorActionPart<'a> { + kind: &'static str, + error: &'a ErrorInfo, + #[serde(skip_serializing_if = "Option::is_none")] + resumable: Option, +} + +impl Serialize for ChatErrorAction { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + + let mut state = serializer + .serialize_struct("ChatErrorAction", if self.meta.is_some() { 4 } else { 3 })?; + state.serialize_field("turnId", &self.turn_id)?; + state.serialize_field("duration", &self.duration)?; + state.serialize_field( + "part", + &ChatErrorActionPart { + kind: "error", + error: &self.part.error, + resumable: self.part.resumable, + }, + )?; + if let Some(meta) = &self.meta { + state.serialize_field("_meta", meta)?; + } + state.end() + } +} + +/// Resumes the latest errored turn without adding another message. +/// +/// The turn MUST be the latest turn, its state MUST be `error`, and its final +/// response part MUST be a resumable error. The reducer reopens the same turn +/// with its existing message, response parts, and usage intact. The host then +/// resumes the provider's execution for that turn. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatTurnResumeAction { + /// Identifier of the errored turn. + pub turn_id: String, +} + /// The activity description of this chat changed. /// /// Dispatched by the server to indicate what the chat is currently doing @@ -2182,6 +2237,8 @@ pub enum StateAction { ChatTurnCancelled(ChatTurnCancelledAction), #[serde(rename = "chat/error")] ChatError(ChatErrorAction), + #[serde(rename = "chat/turnResume")] + ChatTurnResume(ChatTurnResumeAction), #[serde(rename = "chat/activityChanged")] ChatActivityChanged(ChatActivityChangedAction), #[serde(rename = "session/titleChanged")] diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 4a9c04d4b..05f70b1e8 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -521,6 +521,7 @@ pub enum ResponsePartKind { Reasoning, SystemNotification, InputRequest, + Error, /// Unknown raw value from a newer protocol version, preserved verbatim. Unknown(String), } @@ -537,6 +538,7 @@ impl serde::Serialize for ResponsePartKind { Self::Reasoning => serializer.serialize_str("reasoning"), Self::SystemNotification => serializer.serialize_str("systemNotification"), Self::InputRequest => serializer.serialize_str("inputRequest"), + Self::Error => serializer.serialize_str("error"), Self::Unknown(value) => serializer.serialize_str(value), } } @@ -555,6 +557,7 @@ impl<'de> serde::Deserialize<'de> for ResponsePartKind { "reasoning" => Self::Reasoning, "systemNotification" => Self::SystemNotification, "inputRequest" => Self::InputRequest, + "error" => Self::Error, _ => Self::Unknown(raw), }) } @@ -2510,9 +2513,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. @@ -3180,6 +3180,25 @@ pub struct InputRequestResponsePart { pub response: Option, } +/// An error encountered while processing a turn. +/// +/// This is the detailed source of truth for the error. {@link Turn.state} +/// remains {@link TurnState.Error} while the turn is stopped at this error so +/// clients can detect the terminal state without inspecting response parts. +/// +/// When {@link resumable} is `true`, a client may dispatch `chat/turnResume` +/// while this is the latest turn and its state is {@link TurnState.Error}. +/// Clients decide whether and how to present that affordance. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorResponsePart { + /// Error details. + pub error: ErrorInfo, + /// Whether the host can resume the turn from this error. Only `true` enables resume. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resumable: Option, +} + /// Tool execution result details, available after execution completes. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -5714,6 +5733,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 6ef94b7ff..7abedffb4 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -59,12 +59,12 @@ use ahp_types::actions::{ use ahp_types::state::{ ActiveTurn, AnnotationsState, AutomationCatalogState, AutomationRunState, ChangesetOperationStatus, ChangesetState, ChangesetStatus, ChatInputRequest, ChatState, - ChildCustomization, ConfirmationOption, Customization, CustomizationEnablement, ErrorInfo, - InputRequestResponsePart, McpServerStartingState, McpServerState, McpServerStoppedState, - PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, RootState, - SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, TerminalCommandPart, - TerminalContentPart, TerminalExitedLifecycleState, TerminalLifecycleState, TerminalState, - TerminalUnclassifiedPart, ToolCallAuthRequiredState, ToolCallCancellationReason, + ChildCustomization, ConfirmationOption, Customization, CustomizationEnablement, + ErrorResponsePart, InputRequestResponsePart, McpServerStartingState, McpServerState, + McpServerStoppedState, PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, + RootState, SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, + TerminalCommandPart, TerminalContentPart, TerminalExitedLifecycleState, TerminalLifecycleState, + TerminalState, TerminalUnclassifiedPart, ToolCallAuthRequiredState, ToolCallCancellationReason, ToolCallCancelledState, ToolCallCompletedState, ToolCallConfirmationReason, ToolCallContributor, ToolCallPendingConfirmationState, ToolCallPendingResultConfirmationState, ToolCallResponsePart, ToolCallRunningState, ToolCallState, ToolCallStatus, @@ -338,7 +338,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; @@ -358,7 +358,7 @@ fn end_turn( return ReduceOutcome::NoOp; }; - let response_parts: Vec = active + let mut response_parts: Vec = active .response_parts .into_iter() .map(|part| match part { @@ -405,6 +405,9 @@ fn end_turn( other => other, }) .collect(); + if let Some(error_part) = error_part { + response_parts.push(ResponsePart::Error(error_part)); + } let turn = Turn { id: active.id, @@ -414,7 +417,6 @@ fn end_turn( response_parts, usage: active.usage, state: turn_state, - error, }; state.turns.push(turn); @@ -637,7 +639,8 @@ where ResponsePart::ToolCall(tc) => Some(tool_call_id(&tc.tool_call).to_owned()), ResponsePart::Markdown(m) => Some(m.id.clone()), ResponsePart::Reasoning(r) => Some(r.id.clone()), - ResponsePart::ContentRef(_) + ResponsePart::Error(_) + | ResponsePart::ContentRef(_) | ResponsePart::SystemNotification(_) | ResponsePart::InputRequest(_) | ResponsePart::Unknown(_) => None, @@ -1033,6 +1036,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 } @@ -1058,8 +1064,39 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu a.duration, TurnState::Error, Some(SessionStatus::Error), - Some(a.error.clone()), + Some(a.part.clone()), ), + StateAction::ChatTurnResume(a) => { + if state.active_turn.is_some() { + return ReduceOutcome::NoOp; + } + let Some(turn) = state.turns.last_mut() else { + return ReduceOutcome::NoOp; + }; + if turn.id != a.turn_id || turn.state != TurnState::Error { + return ReduceOutcome::NoOp; + } + let Some(ResponsePart::Error(error)) = turn.response_parts.last() else { + return ReduceOutcome::NoOp; + }; + if error.resumable != Some(true) { + return ReduceOutcome::NoOp; + } + + let Some(turn) = state.turns.pop() else { + return ReduceOutcome::NoOp; + }; + 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); + ReduceOutcome::Applied + } StateAction::ChatActivityChanged(a) => { state.activity = a.activity.clone(); ReduceOutcome::Applied diff --git a/clients/swift/AHPApp/AHPApp/Views/ChatView.swift b/clients/swift/AHPApp/AHPApp/Views/ChatView.swift index 060e4b9b1..fc872ff11 100644 --- a/clients/swift/AHPApp/AHPApp/Views/ChatView.swift +++ b/clients/swift/AHPApp/AHPApp/Views/ChatView.swift @@ -772,16 +772,6 @@ struct TurnView: View { ResponsePartView(part: part) } - // Turn status footer - if turn.state == .error, let error = turn.error { - Label(error.message, systemImage: "exclamationmark.triangle.fill") - .font(.footnote) - .foregroundStyle(.red) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.red.opacity(0.1), in: Capsule()) - } - if turn.state == .cancelled { Label("Cancelled", systemImage: "xmark.circle") .font(.footnote) diff --git a/clients/swift/AHPApp/AHPApp/Views/ResponsePartView.swift b/clients/swift/AHPApp/AHPApp/Views/ResponsePartView.swift index 21938d075..b27278448 100644 --- a/clients/swift/AHPApp/AHPApp/Views/ResponsePartView.swift +++ b/clients/swift/AHPApp/AHPApp/Views/ResponsePartView.swift @@ -26,10 +26,29 @@ struct ResponsePartView: View { ContentRefView(ref: ref) case .systemNotification(let note): SystemNotificationPartView(part: note) + case .error(let error): + ErrorResponsePartView(part: error) + case .inputRequest, .unknown: + EmptyView() } } } +// MARK: - ErrorResponsePartView + +struct ErrorResponsePartView: View { + let part: ErrorResponsePart + + var body: some View { + Label(part.error.message, systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(.red) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.red.opacity(0.1), in: Capsule()) + } +} + // MARK: - SystemNotificationPartView struct SystemNotificationPartView: View { diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 021d4d09f..410137ab1 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: Codable, Sendable, Equatable { case chatTurnComplete case chatTurnCancelled case chatError + case chatTurnResume case chatActivityChanged case chatWorkingDirectorySet case chatWorkingDirectoryRemoved @@ -131,6 +132,7 @@ public enum ActionType: Codable, Sendable, Equatable { case "chat/turnComplete": self = .chatTurnComplete case "chat/turnCancelled": self = .chatTurnCancelled case "chat/error": self = .chatError + case "chat/turnResume": self = .chatTurnResume case "chat/activityChanged": self = .chatActivityChanged case "chat/workingDirectorySet": self = .chatWorkingDirectorySet case "chat/workingDirectoryRemoved": self = .chatWorkingDirectoryRemoved @@ -233,6 +235,7 @@ public enum ActionType: Codable, Sendable, Equatable { case .chatTurnComplete: try container.encode("chat/turnComplete") case .chatTurnCancelled: try container.encode("chat/turnCancelled") case .chatError: try container.encode("chat/error") + case .chatTurnResume: try container.encode("chat/turnResume") case .chatActivityChanged: try container.encode("chat/activityChanged") case .chatWorkingDirectorySet: try container.encode("chat/workingDirectorySet") case .chatWorkingDirectoryRemoved: try container.encode("chat/workingDirectoryRemoved") @@ -554,7 +557,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. /// @@ -1110,8 +1113,9 @@ public struct ChatErrorAction: Codable, Sendable { /// client clocks may differ — and MUST treat it as opaque, producer-supplied /// data. public var duration: Int - /// Error details - public var error: ErrorInfo + /// Error part to append to the response stream before finalizing the turn. + /// Its optional `resumable` flag indicates whether the turn can be resumed. + public var part: ErrorResponsePart /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -1125,7 +1129,7 @@ public struct ChatErrorAction: Codable, Sendable { case type case turnId case duration - case error + case part case meta = "_meta" } @@ -1133,17 +1137,31 @@ public struct ChatErrorAction: Codable, Sendable { type: ActionType, turnId: String, duration: Int, - error: ErrorInfo, + part: ErrorResponsePart, meta: [String: AnyCodable]? = nil ) { self.type = type self.turnId = turnId self.duration = duration - self.error = error + self.part = part self.meta = meta } } +public struct ChatTurnResumeAction: Codable, Sendable { + public var type: ActionType + /// Identifier of the errored turn. + public var turnId: String + + public init( + type: ActionType, + turnId: String + ) { + self.type = type + self.turnId = turnId + } +} + public struct ChatActivityChangedAction: Codable, Sendable { public var type: ActionType /// Human-readable description of current activity; omit or set `undefined` to clear @@ -2399,6 +2417,7 @@ public enum StateAction: Codable, Sendable { case chatTurnComplete(ChatTurnCompleteAction) case chatTurnCancelled(ChatTurnCancelledAction) case chatError(ChatErrorAction) + case chatTurnResume(ChatTurnResumeAction) case chatActivityChanged(ChatActivityChangedAction) case sessionTitleChanged(SessionTitleChangedAction) case chatUsage(ChatUsageAction) @@ -2529,6 +2548,8 @@ public enum StateAction: Codable, Sendable { self = .chatTurnCancelled(try ChatTurnCancelledAction(from: decoder)) case "chat/error": self = .chatError(try ChatErrorAction(from: decoder)) + case "chat/turnResume": + self = .chatTurnResume(try ChatTurnResumeAction(from: decoder)) case "chat/activityChanged": self = .chatActivityChanged(try ChatActivityChangedAction(from: decoder)) case "session/titleChanged": @@ -2703,6 +2724,7 @@ public enum StateAction: Codable, Sendable { case .chatTurnComplete(let v): try v.encode(to: encoder) case .chatTurnCancelled(let v): try v.encode(to: encoder) case .chatError(let v): try v.encode(to: encoder) + case .chatTurnResume(let v): try v.encode(to: encoder) case .chatActivityChanged(let v): try v.encode(to: encoder) case .sessionTitleChanged(let v): try v.encode(to: encoder) case .chatUsage(let v): try v.encode(to: encoder) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 4d9322729..606b399e0 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -393,6 +393,7 @@ public enum ResponsePartKind: Codable, Sendable, Equatable { case reasoning case systemNotification case inputRequest + case error /// Unknown raw value from a newer protocol version, preserved verbatim. case unknown(String) @@ -406,6 +407,7 @@ public enum ResponsePartKind: Codable, Sendable, Equatable { case "reasoning": self = .reasoning case "systemNotification": self = .systemNotification case "inputRequest": self = .inputRequest + case "error": self = .error default: self = .unknown(raw) } } @@ -419,6 +421,7 @@ public enum ResponsePartKind: Codable, Sendable, Equatable { case .reasoning: try container.encode("reasoning") case .systemNotification: try container.encode("systemNotification") case .inputRequest: try container.encode("inputRequest") + case .error: try container.encode("error") case .unknown(let raw): try container.encode(raw) } } @@ -2272,8 +2275,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, @@ -2282,8 +2283,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 @@ -2292,7 +2292,6 @@ public struct Turn: Codable, Sendable { self.responseParts = responseParts self.usage = usage self.state = state - self.error = error } } @@ -3257,6 +3256,25 @@ public struct InputRequestResponsePart: Codable, Sendable { } } +public struct ErrorResponsePart: Codable, Sendable { + /// Discriminant + public var kind: ResponsePartKind + /// Error details. + public var error: ErrorInfo + /// Whether the host can resume the turn from this error. Only `true` enables resume. + public var resumable: Bool? + + public init( + kind: ResponsePartKind, + error: ErrorInfo, + resumable: Bool? = nil + ) { + self.kind = kind + self.error = error + self.resumable = resumable + } +} + public struct ToolCallResult: Codable, Sendable { /// Whether the tool succeeded public var success: Bool @@ -6784,6 +6802,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) @@ -6811,6 +6830,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)) } @@ -6824,6 +6845,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 46b0f4f41..b7c553842 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -159,6 +159,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 @@ -171,7 +174,30 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { return endTurn(state: state, turnId: a.turnId, duration: a.duration, turnState: .cancelled) case .chatError(let a): - return endTurn(state: state, turnId: a.turnId, duration: a.duration, turnState: .error, terminalStatus: .error, error: a.error) + return endTurn(state: state, turnId: a.turnId, duration: a.duration, turnState: .error, terminalStatus: .error, errorPart: a.part) + + case .chatTurnResume(let a): + guard state.activeTurn == nil, + let turn = state.turns.last, + turn.id == a.turnId, + turn.state == .error, + case .error(let errorPart) = turn.responseParts.last, + errorPart.resumable == true + else { + return state + } + + var next = state + next.turns.removeLast() + next.activeTurn = ActiveTurn( + id: turn.id, + startedAt: turn.startedAt ?? state.modifiedAt, + message: turn.message, + responseParts: turn.responseParts, + usage: turn.usage + ) + next.status = withStatusFlag(chatSummaryStatus(next), .isRead, false) + return next case .chatActivityChanged(let a): var next = state @@ -892,6 +918,7 @@ public func sessionReducer(state: SessionState, action: StateAction) -> SessionS /// Set of action types that clients are allowed to dispatch. public let clientDispatchableActions: Set = [ "chat/turnStarted", + "chat/turnResume", "chat/toolCallConfirmed", "chat/toolCallComplete", "chat/toolCallResultConfirmed", @@ -914,7 +941,8 @@ public let clientDispatchableActions: Set = [ /// Checks whether an action may be dispatched by a client. public func isClientDispatchable(_ action: StateAction) -> Bool { switch action { - case .chatTurnStarted, .chatToolCallConfirmed, .chatToolCallComplete, + case .chatTurnStarted, .chatTurnResume, + .chatToolCallConfirmed, .chatToolCallComplete, .chatToolCallResultConfirmed, .chatTurnCancelled, .sessionActiveClientSet, .sessionActiveClientRemoved, @@ -1063,13 +1091,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 { @@ -1109,6 +1137,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. @@ -1119,8 +1150,7 @@ private func endTurn( message: activeTurn.message, responseParts: responseParts, usage: activeTurn.usage, - state: turnState, - error: error + state: turnState ) var next = state diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift index 93e7ecb20..e76cee02a 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift @@ -98,6 +98,7 @@ extension ResponsePart { case .contentRef: return nil case .systemNotification: return nil case .inputRequest: return nil + case .error: return nil case .unknown: return nil } } diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift index d769d9693..ffff70452 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift @@ -88,11 +88,17 @@ final class ReducersTests: XCTestCase { // MARK: - Dispatch Validation func testClientDispatchableReturnsTrue() { - let action: StateAction = .chatTurnStarted(ChatTurnStartedAction( - type: .chatTurnStarted, turnId: T, startedAt: "2026-07-09T20:00:00.000Z", - message: Message(text: "Hello", origin: MessageOrigin(kind: .user)) - )) - XCTAssertTrue(isClientDispatchable(action)) + let actions: [StateAction] = [ + .chatTurnStarted(ChatTurnStartedAction( + type: .chatTurnStarted, turnId: T, startedAt: "2026-07-09T20:00:00.000Z", + message: Message(text: "Hello", origin: MessageOrigin(kind: .user)) + )), + .chatTurnResume(ChatTurnResumeAction( + type: .chatTurnResume, + turnId: T + )), + ] + XCTAssertTrue(actions.allSatisfy(isClientDispatchable)) } func testAutomationCancellationIsClientDispatchable() { 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 77bab1333..9237d30ca 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -73,7 +73,8 @@ When a client dispatches an action, the server applies it to the state and also | `chat/usage` | No | Token usage report for the active turn | | `chat/turnComplete` | No | Turn finished (assistant idle) | | `chat/turnCancelled` | **Yes** | Turn was aborted; server stops processing | -| `chat/error` | No | Error during turn processing | +| `chat/error` | No | Error during turn processing; appends an error response part and ends the turn | +| `chat/turnResume` | **Yes** | Resume the latest resumable errored turn without adding another message | | `chat/truncated` | **Yes** | Turn history truncated (with optional `turnId` cutoff) | ### Tool Calls (chat channel) diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index e9df9b867..45a787268 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -187,10 +187,13 @@ Turn { responseParts: ResponsePart[] // all content in stream order usage: UsageInfo | undefined state: 'complete' | 'cancelled' | 'error' - error?: ErrorInfo } ``` +`state: 'error'` is the convenient top-level signal that processing stopped on +an error. The detailed error and any recovery interaction live in an +`ErrorResponsePart`, preserving their position in the response stream. + ### Active Turn An in-progress turn where the assistant is actively streaming: @@ -344,6 +347,13 @@ InputRequestResponsePart { request: ChatInputRequest // the resolved request, with its final answers response: ChatInputResponseKind // 'accept' | 'decline' | 'cancel' } + +// Durable error record +ErrorResponsePart { + kind: 'error' + error: ErrorInfo + resumable?: boolean +} ``` `SystemNotificationResponsePart._meta` carries provider-specific metadata describing what triggered the notification. A host MAY attach a machine-readable descriptor so clients can categorize, icon, group, filter, or localize the notification without parsing `content`. Clients MAY inspect well-known keys for enhanced UI, and MUST render coherently from `content` alone when `_meta` is absent or unrecognized. @@ -354,6 +364,11 @@ Clients fetch `ContentRef` content separately via the `resourceRead(uri)` comman Consumers can derive display text by concatenating all `markdown` parts, find tool calls by filtering for `toolCall` parts, and access reasoning by filtering for `reasoning` parts. +When the latest errored turn ends in an error part with `resumable: true`, a +client may dispatch `chat/turnResume`. The reducer reopens the same turn without +adding a user message. If processing fails again, the host appends another +error part; prior errors remain in stream order. + ## Tool Call Lifecycle Tool calls are represented as a discriminated union on `status`, where each state only exposes the fields valid for that phase. diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 414e69ebe..868195673 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -176,9 +176,31 @@ Once a chat exists and its session is `lifecycle: 'ready'`, the chat accepts tur - The client dispatches `chat/toolCallConfirmed` / `chat/toolCallResultConfirmed` to approve or deny tool calls, or `chat/turnCancelled` to abort. - The server dispatches `chat/turnComplete` or `chat/error` when the turn ends. - The server MAY dispatch `chat/inputRequested` while a turn is active. Clients sync answer drafts with `chat/inputAnswerChanged` and finish the request with `chat/inputCompleted`. +- A `chat/error` appends an error response part before setting the turn state to `error`. When that part has `resumable: true`, a client may dispatch `chat/turnResume` to continue the same turn without another user message. All actions dispatched on this channel travel on `ActionEnvelope`s whose `channel` is the chat URI. Action payloads do NOT carry their own chat URI — the channel comes from the envelope. +### Error recovery + +An error ends the active turn with `TurnState.Error`, providing a simple +top-level signal for clients that do not implement resume. Its +`ErrorResponsePart` is the detailed source of truth: it contains `ErrorInfo` +and may declare the turn resumable with `resumable: true`. Clients decide whether and how to present +that affordance. + +Errors MUST enter the response stream through `chat/error`; reducers ignore an +error part sent through generic `chat/responsePart`. This keeps appending the +detailed error and ending the turn as one atomic state transition. + +Dispatching `chat/turnResume` reopens the same turn. The original message, turn +identifier, response parts, and usage are retained. A successful continuation +eventually finalizes that turn as complete. If continuation fails, `chat/error` +appends another error part. This preserves every failure in response-stream +order without creating a synthetic turn or message. + +The server MUST validate and sequence the resume action before invoking the +provider. A rejected or stale resume MUST NOT produce side effects. + ### Tool call metadata refinement A host MAY open a tool call before all display metadata is known so clients can @@ -227,6 +249,7 @@ When the server receives a client-dispatched action on this channel, it MUST val | Any action referencing a non-existent chat | Channel URI not found | Server MUST silently ignore the action (no echo) | | `chat/toolCallConfirmed` | Tool call not in `pending-confirmation` state | Server MUST reject the action | | `chat/turnCancelled` | No active turn | Server MUST reject the action | +| `chat/turnResume` | An active turn exists, `turnId` is not the latest errored turn, or its final error part is not resumable | Server MUST reject the action | | `chat/inputAnswerChanged` | No input request with matching `requestId` | Server SHOULD reject the action | | `chat/inputAnswerChanged` | `answer.state` requires a value but `answer.value` is absent, or `answer.value.kind` is missing the matching payload field | Server SHOULD reject the action | | `chat/inputCompleted` | No input request with matching `requestId` | Server SHOULD reject the action | diff --git a/schema/actions.schema.json b/schema/actions.schema.json index e2f70b571..a62c111d6 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -766,7 +766,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" @@ -777,7 +777,7 @@ }, "part": { "$ref": "#/$defs/ResponsePart", - "description": "Response part (markdown or content ref)" + "description": "Response part to append; error parts are ignored." }, "_meta": { "type": "object", @@ -1283,9 +1283,9 @@ "type": "number", "description": "Elapsed turn duration in milliseconds, measured by the producer's own\nclock. Clients MUST NOT derive this by subtracting timestamps — cross-\nclient clocks may differ — and MUST treat it as opaque, producer-supplied\ndata." }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details" + "part": { + "$ref": "#/$defs/ErrorResponsePart", + "description": "Error part to append to the response stream before finalizing the turn.\nIts optional `resumable` flag indicates whether the turn can be resumed." }, "_meta": { "type": "object", @@ -1297,7 +1297,24 @@ "type", "turnId", "duration", - "error" + "part" + ] + }, + "ChatTurnResumeAction": { + "type": "object", + "description": "Resumes the latest errored turn without adding another message.\n\nThe turn MUST be the latest turn, its state MUST be `error`, and its final\nresponse part MUST be a resumable error. The reducer reopens the same turn\nwith its existing message, response parts, and usage intact. The host then\nresumes the provider's execution for that turn.", + "properties": { + "type": { + "const": "chat/turnResume" + }, + "turnId": { + "type": "string", + "description": "Identifier of the errored turn." + } + }, + "required": [ + "type", + "turnId" ] }, "ChatActivityChangedAction": { @@ -2357,6 +2374,9 @@ { "$ref": "#/$defs/ChatErrorAction" }, + { + "$ref": "#/$defs/ChatTurnResumeAction" + }, { "$ref": "#/$defs/ChatActivityChangedAction" }, @@ -5380,10 +5400,6 @@ "state": { "$ref": "#/$defs/TurnState", "description": "How the turn ended" - }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details if state is `'error'`" } }, "required": [ @@ -5835,6 +5851,28 @@ "request" ] }, + "ErrorResponsePart": { + "type": "object", + "description": "An error encountered while processing a turn.\n\nThis is the detailed source of truth for the error. {@link Turn.state}\nremains {@link TurnState.Error} while the turn is stopped at this error so\nclients can detect the terminal state without inspecting response parts.\n\nWhen {@link resumable} is `true`, a client may dispatch `chat/turnResume`\nwhile this is the latest turn and its state is {@link TurnState.Error}.\nClients decide whether and how to present that affordance.", + "properties": { + "kind": { + "const": "error", + "description": "Discriminant" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Error details." + }, + "resumable": { + "type": "boolean", + "description": "Whether the host can resume the turn from this error. Only `true` enables resume." + } + }, + "required": [ + "kind", + "error" + ] + }, "SystemNotificationResponsePart": { "type": "object", "description": "A system notification surfaced as part of the response stream.\n\nSystem notifications are messages authored by the agent harness\nthat need to be visible to both the agent (for situational awareness) and\nthe user (for transcript continuity). Examples include \"background subagent\nX completed\" or \"task Y was cancelled\".", @@ -8319,6 +8357,9 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + }, + { + "$ref": "#/$defs/ErrorResponsePart" } ] }, @@ -8635,6 +8676,9 @@ { "$ref": "#/$defs/ChatErrorAction" }, + { + "$ref": "#/$defs/ChatTurnResumeAction" + }, { "$ref": "#/$defs/ChatActivityChangedAction" }, diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 7c0d7a701..429ba8a2a 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -4634,10 +4634,6 @@ "state": { "$ref": "#/$defs/TurnState", "description": "How the turn ended" - }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details if state is `'error'`" } }, "required": [ @@ -5089,6 +5085,28 @@ "request" ] }, + "ErrorResponsePart": { + "type": "object", + "description": "An error encountered while processing a turn.\n\nThis is the detailed source of truth for the error. {@link Turn.state}\nremains {@link TurnState.Error} while the turn is stopped at this error so\nclients can detect the terminal state without inspecting response parts.\n\nWhen {@link resumable} is `true`, a client may dispatch `chat/turnResume`\nwhile this is the latest turn and its state is {@link TurnState.Error}.\nClients decide whether and how to present that affordance.", + "properties": { + "kind": { + "const": "error", + "description": "Discriminant" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Error details." + }, + "resumable": { + "type": "boolean", + "description": "Whether the host can resume the turn from this error. Only `true` enables resume." + } + }, + "required": [ + "kind", + "error" + ] + }, "SystemNotificationResponsePart": { "type": "object", "description": "A system notification surfaced as part of the response stream.\n\nSystem notifications are messages authored by the agent harness\nthat need to be visible to both the agent (for situational awareness) and\nthe user (for transcript continuity). Examples include \"background subagent\nX completed\" or \"task Y was cancelled\".", @@ -7961,7 +7979,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" @@ -7972,7 +7990,7 @@ }, "part": { "$ref": "#/$defs/ResponsePart", - "description": "Response part (markdown or content ref)" + "description": "Response part to append; error parts are ignored." }, "_meta": { "type": "object", @@ -8478,9 +8496,9 @@ "type": "number", "description": "Elapsed turn duration in milliseconds, measured by the producer's own\nclock. Clients MUST NOT derive this by subtracting timestamps — cross-\nclient clocks may differ — and MUST treat it as opaque, producer-supplied\ndata." }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details" + "part": { + "$ref": "#/$defs/ErrorResponsePart", + "description": "Error part to append to the response stream before finalizing the turn.\nIts optional `resumable` flag indicates whether the turn can be resumed." }, "_meta": { "type": "object", @@ -8492,7 +8510,24 @@ "type", "turnId", "duration", - "error" + "part" + ] + }, + "ChatTurnResumeAction": { + "type": "object", + "description": "Resumes the latest errored turn without adding another message.\n\nThe turn MUST be the latest turn, its state MUST be `error`, and its final\nresponse part MUST be a resumable error. The reducer reopens the same turn\nwith its existing message, response parts, and usage intact. The host then\nresumes the provider's execution for that turn.", + "properties": { + "type": { + "const": "chat/turnResume" + }, + "turnId": { + "type": "string", + "description": "Identifier of the errored turn." + } + }, + "required": [ + "type", + "turnId" ] }, "ChatActivityChangedAction": { @@ -9641,6 +9676,9 @@ { "$ref": "#/$defs/ChatErrorAction" }, + { + "$ref": "#/$defs/ChatTurnResumeAction" + }, { "$ref": "#/$defs/ChatActivityChangedAction" }, @@ -10328,6 +10366,9 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + }, + { + "$ref": "#/$defs/ErrorResponsePart" } ] }, diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 4d579cc28..7fc9e5eb8 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -3073,10 +3073,6 @@ "state": { "$ref": "#/$defs/TurnState", "description": "How the turn ended" - }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details if state is `'error'`" } }, "required": [ @@ -3528,6 +3524,28 @@ "request" ] }, + "ErrorResponsePart": { + "type": "object", + "description": "An error encountered while processing a turn.\n\nThis is the detailed source of truth for the error. {@link Turn.state}\nremains {@link TurnState.Error} while the turn is stopped at this error so\nclients can detect the terminal state without inspecting response parts.\n\nWhen {@link resumable} is `true`, a client may dispatch `chat/turnResume`\nwhile this is the latest turn and its state is {@link TurnState.Error}.\nClients decide whether and how to present that affordance.", + "properties": { + "kind": { + "const": "error", + "description": "Discriminant" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Error details." + }, + "resumable": { + "type": "boolean", + "description": "Whether the host can resume the turn from this error. Only `true` enables resume." + } + }, + "required": [ + "kind", + "error" + ] + }, "SystemNotificationResponsePart": { "type": "object", "description": "A system notification surfaced as part of the response stream.\n\nSystem notifications are messages authored by the agent harness\nthat need to be visible to both the agent (for situational awareness) and\nthe user (for transcript continuity). Examples include \"background subagent\nX completed\" or \"task Y was cancelled\".", @@ -7686,6 +7704,9 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + }, + { + "$ref": "#/$defs/ErrorResponsePart" } ] }, @@ -8169,6 +8190,9 @@ { "$ref": "#/$defs/ChatErrorAction" }, + { + "$ref": "#/$defs/ChatTurnResumeAction" + }, { "$ref": "#/$defs/ChatActivityChangedAction" }, @@ -9124,7 +9148,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" @@ -9135,7 +9159,7 @@ }, "part": { "$ref": "#/$defs/ResponsePart", - "description": "Response part (markdown or content ref)" + "description": "Response part to append; error parts are ignored." }, "_meta": { "type": "object", @@ -9553,9 +9577,9 @@ "type": "number", "description": "Elapsed turn duration in milliseconds, measured by the producer's own\nclock. Clients MUST NOT derive this by subtracting timestamps — cross-\nclient clocks may differ — and MUST treat it as opaque, producer-supplied\ndata." }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details" + "part": { + "$ref": "#/$defs/ErrorResponsePart", + "description": "Error part to append to the response stream before finalizing the turn.\nIts optional `resumable` flag indicates whether the turn can be resumed." }, "_meta": { "type": "object", @@ -9567,7 +9591,24 @@ "type", "turnId", "duration", - "error" + "part" + ] + }, + "ChatTurnResumeAction": { + "type": "object", + "description": "Resumes the latest errored turn without adding another message.\n\nThe turn MUST be the latest turn, its state MUST be `error`, and its final\nresponse part MUST be a resumable error. The reducer reopens the same turn\nwith its existing message, response parts, and usage intact. The host then\nresumes the provider's execution for that turn.", + "properties": { + "type": { + "const": "chat/turnResume" + }, + "turnId": { + "type": "string", + "description": "Identifier of the errored turn." + } + }, + "required": [ + "type", + "turnId" ] }, "ChatActivityChangedAction": { diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 2f3594275..4acf66148 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -3240,10 +3240,6 @@ "state": { "$ref": "#/$defs/TurnState", "description": "How the turn ended" - }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details if state is `'error'`" } }, "required": [ @@ -3695,6 +3691,28 @@ "request" ] }, + "ErrorResponsePart": { + "type": "object", + "description": "An error encountered while processing a turn.\n\nThis is the detailed source of truth for the error. {@link Turn.state}\nremains {@link TurnState.Error} while the turn is stopped at this error so\nclients can detect the terminal state without inspecting response parts.\n\nWhen {@link resumable} is `true`, a client may dispatch `chat/turnResume`\nwhile this is the latest turn and its state is {@link TurnState.Error}.\nClients decide whether and how to present that affordance.", + "properties": { + "kind": { + "const": "error", + "description": "Discriminant" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Error details." + }, + "resumable": { + "type": "boolean", + "description": "Whether the host can resume the turn from this error. Only `true` enables resume." + } + }, + "required": [ + "kind", + "error" + ] + }, "SystemNotificationResponsePart": { "type": "object", "description": "A system notification surfaced as part of the response stream.\n\nSystem notifications are messages authored by the agent harness\nthat need to be visible to both the agent (for situational awareness) and\nthe user (for transcript continuity). Examples include \"background subagent\nX completed\" or \"task Y was cancelled\".", @@ -6211,6 +6229,9 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + }, + { + "$ref": "#/$defs/ErrorResponsePart" } ] }, diff --git a/schema/state.schema.json b/schema/state.schema.json index d3775eab6..fb3623e33 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -2984,10 +2984,6 @@ "state": { "$ref": "#/$defs/TurnState", "description": "How the turn ended" - }, - "error": { - "$ref": "#/$defs/ErrorInfo", - "description": "Error details if state is `'error'`" } }, "required": [ @@ -3439,6 +3435,28 @@ "request" ] }, + "ErrorResponsePart": { + "type": "object", + "description": "An error encountered while processing a turn.\n\nThis is the detailed source of truth for the error. {@link Turn.state}\nremains {@link TurnState.Error} while the turn is stopped at this error so\nclients can detect the terminal state without inspecting response parts.\n\nWhen {@link resumable} is `true`, a client may dispatch `chat/turnResume`\nwhile this is the latest turn and its state is {@link TurnState.Error}.\nClients decide whether and how to present that affordance.", + "properties": { + "kind": { + "const": "error", + "description": "Discriminant" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Error details." + }, + "resumable": { + "type": "boolean", + "description": "Whether the host can resume the turn from this error. Only `true` enables resume." + } + }, + "required": [ + "kind", + "error" + ] + }, "SystemNotificationResponsePart": { "type": "object", "description": "A system notification surfaced as part of the response stream.\n\nSystem notifications are messages authored by the agent harness\nthat need to be visible to both the agent (for situational awareness) and\nthe user (for transcript continuity). Examples include \"background subagent\nX completed\" or \"task Y was cancelled\".", @@ -5923,6 +5941,9 @@ }, { "$ref": "#/$defs/InputRequestResponsePart" + }, + { + "$ref": "#/$defs/ErrorResponsePart" } ] }, diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts index 4c5711d05..98d8c0abe 100644 --- a/scripts/generate-csharp.ts +++ b/scripts/generate-csharp.ts @@ -713,6 +713,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: strin { name: 'ReasoningResponsePart', mutable: true }, { name: 'SystemNotificationResponsePart' }, { name: 'InputRequestResponsePart' }, + { name: 'ErrorResponsePart' }, { name: 'ToolCallResult' }, { name: 'ConfirmationOption' }, { name: 'ToolCallRiskAssessmentLoadingState' }, @@ -815,6 +816,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, }; @@ -1477,6 +1479,7 @@ const ACTION_VARIANTS: { type: string; variantName: string; tsInterface: string { type: 'chat/turnComplete', variantName: 'ChatTurnComplete', tsInterface: 'ChatTurnCompleteAction' }, { type: 'chat/turnCancelled', variantName: 'ChatTurnCancelled', tsInterface: 'ChatTurnCancelledAction' }, { type: 'chat/error', variantName: 'ChatError', tsInterface: 'ChatErrorAction' }, + { type: 'chat/turnResume', variantName: 'ChatTurnResume', tsInterface: 'ChatTurnResumeAction' }, { type: 'chat/activityChanged', variantName: 'ChatActivityChanged', tsInterface: 'ChatActivityChangedAction' }, { type: 'chat/workingDirectorySet', variantName: 'ChatWorkingDirectorySet', tsInterface: 'ChatWorkingDirectorySetAction' }, { type: 'chat/workingDirectoryRemoved', variantName: 'ChatWorkingDirectoryRemoved', tsInterface: 'ChatWorkingDirectoryRemovedAction' }, diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 1a747b113..9831f6748 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -793,6 +793,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'ReasoningResponsePart' }, { name: 'SystemNotificationResponsePart' }, { name: 'InputRequestResponsePart' }, + { name: 'ErrorResponsePart' }, { name: 'ToolCallResult' }, { name: 'ToolCallRiskAssessmentLoadingState' }, { name: 'ToolCallRiskAssessmentCompleteState' }, @@ -895,6 +896,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, }; @@ -1536,6 +1538,7 @@ const ACTION_VARIANTS: { { type: 'chat/turnComplete', variantName: 'ChatTurnComplete', tsInterface: 'ChatTurnCompleteAction' }, { type: 'chat/turnCancelled', variantName: 'ChatTurnCancelled', tsInterface: 'ChatTurnCancelledAction' }, { type: 'chat/error', variantName: 'ChatError', tsInterface: 'ChatErrorAction' }, + { type: 'chat/turnResume', variantName: 'ChatTurnResume', tsInterface: 'ChatTurnResumeAction' }, { type: 'chat/activityChanged', variantName: 'ChatActivityChanged', tsInterface: 'ChatActivityChangedAction' }, { type: 'session/titleChanged', variantName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', variantName: 'ChatUsage', tsInterface: 'ChatUsageAction' }, diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 25a5e0c98..47cf3827d 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -1008,6 +1008,7 @@ const STATE_STRUCTS = [ 'MarkdownResponsePart', 'ContentRef', 'ResourceResponsePart', 'ToolCallResponsePart', 'ReasoningResponsePart', 'SystemNotificationResponsePart', 'InputRequestResponsePart', + 'ErrorResponsePart', 'ToolCallResult', 'ToolCallStreamingState', 'ToolCallPendingConfirmationState', 'ToolCallRunningState', 'ToolCallAuthRequiredState', 'ToolCallPendingResultConfirmationState', 'ToolCallCompletedState', @@ -1059,6 +1060,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, }; @@ -1486,6 +1488,7 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'chat/turnComplete', caseName: 'ChatTurnComplete', tsInterface: 'ChatTurnCompleteAction' }, { type: 'chat/turnCancelled', caseName: 'ChatTurnCancelled', tsInterface: 'ChatTurnCancelledAction' }, { type: 'chat/error', caseName: 'ChatError', tsInterface: 'ChatErrorAction' }, + { type: 'chat/turnResume', caseName: 'ChatTurnResume', tsInterface: 'ChatTurnResumeAction' }, { type: 'chat/activityChanged', caseName: 'ChatActivityChanged', tsInterface: 'ChatActivityChangedAction' }, { type: 'session/titleChanged', caseName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', caseName: 'ChatUsage', tsInterface: 'ChatUsageAction' }, diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 4765e2c41..0b40c2edf 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -567,6 +567,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. */ @@ -583,7 +585,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")]'); @@ -848,6 +854,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'ReasoningResponsePart', omitDiscriminants: true }, { name: 'SystemNotificationResponsePart', omitDiscriminants: true }, { name: 'InputRequestResponsePart', omitDiscriminants: true }, + { name: 'ErrorResponsePart', omitDiscriminants: true }, { name: 'ToolCallResult' }, { name: 'ToolCallRiskAssessmentLoadingState', omitDiscriminants: true }, { name: 'ToolCallRiskAssessmentCompleteState', omitDiscriminants: true }, @@ -950,6 +957,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, }; @@ -1433,6 +1441,7 @@ const ACTION_VARIANTS: { { type: 'chat/turnComplete', variantName: 'ChatTurnComplete', tsInterface: 'ChatTurnCompleteAction' }, { type: 'chat/turnCancelled', variantName: 'ChatTurnCancelled', tsInterface: 'ChatTurnCancelledAction' }, { type: 'chat/error', variantName: 'ChatError', tsInterface: 'ChatErrorAction' }, + { type: 'chat/turnResume', variantName: 'ChatTurnResume', tsInterface: 'ChatTurnResumeAction' }, { type: 'chat/activityChanged', variantName: 'ChatActivityChanged', tsInterface: 'ChatActivityChangedAction' }, { type: 'session/titleChanged', variantName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', variantName: 'ChatUsage', tsInterface: 'ChatUsageAction' }, @@ -1539,10 +1548,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, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationRunLifecycle, AutomationRunSummary, AutomationState, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, 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, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationRunLifecycle, AutomationRunSummary, AutomationState, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, 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 @@ -1591,7 +1639,12 @@ pub struct ActionEnvelope { try { lines.push(generateStructFromInterface(project, v.tsInterface, undefined, { omitDiscriminants: true, + omitSerialize: v.tsInterface === 'ChatErrorAction', })); + if (v.tsInterface === 'ChatErrorAction') { + lines.push(''); + lines.push(generateChatErrorActionSerializeImpl()); + } lines.push(''); } catch (e) { lines.push(`// TODO: could not generate ${v.tsInterface}: ${e}`); diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index dad174e0c..1a94c8c33 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -713,6 +713,7 @@ const STATE_STRUCTS = [ 'MarkdownResponsePart', 'ContentRef', 'ResourceResponsePart', 'ToolCallResponsePart', 'ReasoningResponsePart', 'SystemNotificationResponsePart', 'InputRequestResponsePart', + 'ErrorResponsePart', 'ToolCallResult', 'ToolCallStreamingState', 'ToolCallPendingConfirmationState', 'ToolCallRunningState', 'ToolCallAuthRequiredState', 'ToolCallPendingResultConfirmationState', 'ToolCallCompletedState', @@ -769,6 +770,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' }, ], }; @@ -1383,6 +1385,7 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'chat/turnComplete', caseName: 'chatTurnComplete', tsInterface: 'ChatTurnCompleteAction' }, { type: 'chat/turnCancelled', caseName: 'chatTurnCancelled', tsInterface: 'ChatTurnCancelledAction' }, { type: 'chat/error', caseName: 'chatError', tsInterface: 'ChatErrorAction' }, + { type: 'chat/turnResume', caseName: 'chatTurnResume', tsInterface: 'ChatTurnResumeAction' }, { type: 'chat/activityChanged', caseName: 'chatActivityChanged', tsInterface: 'ChatActivityChangedAction' }, { type: 'session/titleChanged', caseName: 'sessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', caseName: 'chatUsage', tsInterface: 'ChatUsageAction' }, diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index c5475c0ed..b3686f61e 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -50,6 +50,7 @@ import type { ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, + ChatTurnResumeAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, @@ -209,6 +210,7 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatTurnResumeAction | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction @@ -233,6 +235,7 @@ export type ClientChatAction = | ChatToolCallResultConfirmedAction | ChatToolCallContentChangedAction | ChatTurnCancelledAction + | ChatTurnResumeAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction | ChatPendingMessageSetAction @@ -458,6 +461,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatTurnComplete]: false, [ActionType.ChatTurnCancelled]: true, [ActionType.ChatError]: false, + [ActionType.ChatTurnResume]: true, [ActionType.ChatActivityChanged]: false, [ActionType.ChatWorkingDirectorySet]: true, [ActionType.ChatWorkingDirectoryRemoved]: true, diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index bd4e7e36e..8d2b6f2ea 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -5,7 +5,7 @@ */ import { ActionType } from '../common/actions.js'; -import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo, URI } from '../common/state.js'; +import type { StringOrMarkdown, FileEdit, UsageInfo, URI } from '../common/state.js'; import type { McpAuthRequirement } from '../channels-session/state.js'; import type { Message, @@ -16,6 +16,7 @@ import type { ChatInputRequest, ChatInputResponseKind, ConfirmationOption, + ErrorResponsePart, ToolCallContributor, ToolCallRiskAssessment, ToolInput, @@ -118,6 +119,9 @@ export interface ChatDeltaAction { /** * Structured content appended to the response. * + * An {@link ErrorResponsePart} MUST be appended with {@link ChatErrorAction} + * instead so adding the part and ending the turn are one atomic transition. + * * @category Chat Actions * @version 1 */ @@ -125,7 +129,7 @@ export interface ChatResponsePartAction { type: ActionType.ChatResponsePart; /** Turn identifier */ turnId: string; - /** Response part (markdown or content ref) */ + /** Response part to append; error parts are ignored. */ part: ResponsePart; /** * Additional provider-specific metadata for this action. @@ -488,8 +492,11 @@ export interface ChatErrorAction { * data. */ duration: number; - /** Error details */ - error: ErrorInfo; + /** + * Error part to append to the response stream before finalizing the turn. + * Its optional `resumable` flag indicates whether the turn can be resumed. + */ + part: ErrorResponsePart; /** * Additional provider-specific metadata for this action. * @@ -502,6 +509,24 @@ export interface ChatErrorAction { _meta?: Record; } +/** + * Resumes the latest errored turn without adding another message. + * + * The turn MUST be the latest turn, its state MUST be `error`, and its final + * response part MUST be a resumable error. The reducer reopens the same turn + * with its existing message, response parts, and usage intact. The host then + * resumes the provider's execution for that turn. + * + * @category Chat Actions + * @version 1 + * @clientDispatchable + */ +export interface ChatTurnResumeAction { + type: ActionType.ChatTurnResume; + /** Identifier of the errored turn. */ + turnId: string; +} + /** * The activity description of this chat changed. * @@ -821,6 +846,7 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatTurnResumeAction | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index 82ee436ae..41242700b 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, @@ -122,6 +123,15 @@ function findOpenInputRequestPart( return part.kind === ResponsePartKind.InputRequest ? { index, part } : undefined; } +function hasResumableError(turn: Turn): boolean { + const part = turn.responseParts[turn.responseParts.length - 1]; + return part?.kind === ResponsePartKind.Error && part.resumable === true; +} + +function isErrorResponsePart(part: ResponsePart): part is ErrorResponsePart { + return part.kind === ResponsePartKind.Error; +} + /** Bitmask covering the mutually-exclusive activity bits (bits 0–4). */ const STATUS_ACTIVITY_MASK = (1 << 5) - 1; @@ -171,7 +181,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; @@ -198,6 +208,9 @@ function endTurn( }, }; }); + if (errorPart) { + responseParts.push(errorPart); + } const turn: Turn = { id: active.id, @@ -209,7 +222,6 @@ function endTurn( responseParts, usage: active.usage, state: turnState, - error, }; const next: ChatState = { @@ -386,6 +398,9 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st if (!state.activeTurn || state.activeTurn.id !== action.turnId) { return state; } + if (isErrorResponsePart(action.part)) { + return state; + } return { ...state, activeTurn: { @@ -401,7 +416,35 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st return endTurn(state, action.turnId, TurnState.Cancelled, action.duration); case ActionType.ChatError: - return endTurn(state, action.turnId, TurnState.Error, action.duration, SessionStatus.Error, action.error); + return endTurn(state, action.turnId, TurnState.Error, action.duration, SessionStatus.Error, action.part); + + case ActionType.ChatTurnResume: { + if (state.activeTurn) { + return state; + } + const turnIndex = state.turns.length - 1; + const turn = state.turns[turnIndex]; + if (!turn || turn.id !== action.turnId || turn.state !== TurnState.Error || !hasResumableError(turn)) { + return state; + } + const turns = state.turns.slice(); + turns.splice(turnIndex, 1); + const next: ChatState = { + ...state, + turns, + activeTurn: { + id: turn.id, + startedAt: turn.startedAt ?? state.modifiedAt, + message: turn.message, + responseParts: turn.responseParts, + usage: turn.usage, + }, + }; + return { + ...next, + status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), + }; + } case ActionType.ChatActivityChanged: return { ...state, activity: action.activity }; diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index 258e07ca1..a2609165b 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -576,8 +576,6 @@ export interface Turn { usage: UsageInfo | undefined; /** How the turn ended */ state: TurnState; - /** Error details if state is `'error'` */ - error?: ErrorInfo; } /** @@ -878,6 +876,7 @@ export const enum ResponsePartKind { Reasoning = 'reasoning', SystemNotification = 'systemNotification', InputRequest = 'inputRequest', + Error = 'error', } /** @@ -941,7 +940,8 @@ export type ResponsePart = | ToolCallResponsePart | ReasoningResponsePart | SystemNotificationResponsePart - | InputRequestResponsePart; + | InputRequestResponsePart + | ErrorResponsePart; /** * A live or resolved input request (elicitation) in the turn response stream. @@ -972,6 +972,28 @@ export interface InputRequestResponsePart { response?: ChatInputResponseKind; } +/** + * An error encountered while processing a turn. + * + * This is the detailed source of truth for the error. {@link Turn.state} + * remains {@link TurnState.Error} while the turn is stopped at this error so + * clients can detect the terminal state without inspecting response parts. + * + * When {@link resumable} is `true`, a client may dispatch `chat/turnResume` + * while this is the latest turn and its state is {@link TurnState.Error}. + * Clients decide whether and how to present that affordance. + * + * @category Response Parts + */ +export interface ErrorResponsePart { + /** Discriminant */ + kind: ResponsePartKind.Error; + /** Error details. */ + error: ErrorInfo; + /** Whether the host can resume the turn from this error. Only `true` enables resume. */ + resumable?: boolean; +} + /** * A system notification surfaced as part of the response stream. * diff --git a/types/common/actions.ts b/types/common/actions.ts index fef644ebd..99cc42763 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -62,6 +62,7 @@ import type { ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, + ChatTurnResumeAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, @@ -160,6 +161,7 @@ export const enum ActionType { ChatTurnComplete = 'chat/turnComplete', ChatTurnCancelled = 'chat/turnCancelled', ChatError = 'chat/error', + ChatTurnResume = 'chat/turnResume', ChatActivityChanged = 'chat/activityChanged', ChatWorkingDirectorySet = 'chat/workingDirectorySet', ChatWorkingDirectoryRemoved = 'chat/workingDirectoryRemoved', @@ -315,6 +317,7 @@ export type StateAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatTurnResumeAction | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction diff --git a/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json b/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json index b27693293..9c9cfe846 100644 --- a/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json +++ b/types/test-cases/reducers/014-session-turncomplete-finalizes-turn.json @@ -62,8 +62,7 @@ } ], "usage": null, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json b/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json index 618aefb3b..f72613eb1 100644 --- a/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json +++ b/types/test-cases/reducers/015-session-turncancelled-finalizes-turn.json @@ -41,8 +41,7 @@ }, "responseParts": [], "usage": null, - "state": "cancelled", - "error": null + "state": "cancelled" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json b/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json index 6a690e3f3..8a5b0d5de 100644 --- a/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json +++ b/types/test-cases/reducers/016-session-error-finalizes-turn-with-error.json @@ -25,9 +25,12 @@ "type": "chat/error", "turnId": "turn-1", "duration": 8999, - "error": { - "errorType": "runtime", - "message": "Something broke" + "part": { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + } } } ], @@ -43,13 +46,17 @@ "kind": "user" } }, - "responseParts": [], + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + } + } + ], "usage": null, - "state": "error", - "error": { - "errorType": "runtime", - "message": "Something broke" - } + "state": "error" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json b/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json index b56073aaa..29f1f8623 100644 --- a/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json +++ b/types/test-cases/reducers/017-turncomplete-force-cancels-in-progress-tool-calls.json @@ -64,8 +64,7 @@ } ], "usage": null, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json b/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json index 95a3cbefb..1f1a06e2c 100644 --- a/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json +++ b/types/test-cases/reducers/070-full-turn-flow-with-tool-calls-and-re-confirmation.json @@ -212,8 +212,7 @@ "inputTokens": 200, "outputTokens": 100 }, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json b/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json index 5ab99dc42..0e204aaee 100644 --- a/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json +++ b/types/test-cases/reducers/099-endturn-force-cancels-running-tool-call.json @@ -75,8 +75,7 @@ } ], "usage": null, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json b/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json index 750c73816..8e868e077 100644 --- a/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json +++ b/types/test-cases/reducers/107-session-input-turn-end-cleans-turn-scoped-only.json @@ -87,8 +87,7 @@ } ], "usage": null, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json b/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json index 4d04fe63e..2290bdb0b 100644 --- a/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json +++ b/types/test-cases/reducers/161-chat-turn-lifecycle-on-chat.json @@ -64,8 +64,7 @@ } ], "usage": null, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/240-turn-duration-is-clamped-to-zero.json b/types/test-cases/reducers/240-turn-duration-is-clamped-to-zero.json index 390320d13..1d5b98eda 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 577ab25bc..32ce475ab 100644 --- a/types/test-cases/reducers/250-turncomplete-force-cancels-auth-required-tool-call.json +++ b/types/test-cases/reducers/250-turncomplete-force-cancels-auth-required-tool-call.json @@ -94,8 +94,7 @@ } ], "usage": null, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null, diff --git a/types/test-cases/reducers/263-chat-turn-resume-reopens-turn.json b/types/test-cases/reducers/263-chat-turn-resume-reopens-turn.json new file mode 100644 index 000000000..201fe4fad --- /dev/null +++ b/types/test-cases/reducers/263-chat-turn-resume-reopens-turn.json @@ -0,0 +1,79 @@ +{ + "description": "chat/turnResume reopens the latest turn after a resumable error", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 1000, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "markdown", + "id": "markdown-1", + "content": "Partial response" + }, + { + "kind": "error", + "error": { + "errorType": "quota", + "message": "More tokens are required" + }, + "resumable": true + } + ], + "usage": null, + "state": "error" + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnResume", + "turnId": "turn-1" + } + ], + "expected": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "markdown", + "id": "markdown-1", + "content": "Partial response" + }, + { + "kind": "error", + "error": { + "errorType": "quota", + "message": "More tokens are required" + }, + "resumable": true + } + ], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} diff --git a/types/test-cases/reducers/264-chat-turn-resume-noop-with-active-turn.json b/types/test-cases/reducers/264-chat-turn-resume-noop-with-active-turn.json new file mode 100644 index 000000000..04501fd21 --- /dev/null +++ b/types/test-cases/reducers/264-chat-turn-resume-noop-with-active-turn.json @@ -0,0 +1,48 @@ +{ + "description": "chat/turnResume does nothing while a turn is active", + "reducer": "chat", + "initial": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnResume", + "turnId": "turn-1" + } + ], + "expected": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} diff --git a/types/test-cases/reducers/265-chat-turn-resume-noop-for-unknown-turn.json b/types/test-cases/reducers/265-chat-turn-resume-noop-for-unknown-turn.json new file mode 100644 index 000000000..380eef696 --- /dev/null +++ b/types/test-cases/reducers/265-chat-turn-resume-noop-for-unknown-turn.json @@ -0,0 +1,24 @@ +{ + "description": "chat/turnResume does nothing when the turn is unknown", + "reducer": "chat", + "initial": { + "turns": [], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnResume", + "turnId": "turn-1" + } + ], + "expected": { + "turns": [], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} diff --git a/types/test-cases/reducers/266-chat-turn-resume-noop-for-nonlatest-turn.json b/types/test-cases/reducers/266-chat-turn-resume-noop-for-nonlatest-turn.json new file mode 100644 index 000000000..888dbb598 --- /dev/null +++ b/types/test-cases/reducers/266-chat-turn-resume-noop-for-nonlatest-turn.json @@ -0,0 +1,92 @@ +{ + "description": "chat/turnResume does nothing when the errored turn is not latest", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "resumable": true + } + ], + "usage": null, + "state": "error" + }, + { + "id": "turn-2", + "message": { + "text": "Second", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnResume", + "turnId": "turn-1" + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "resumable": true + } + ], + "usage": null, + "state": "error" + }, + { + "id": "turn-2", + "message": { + "text": "Second", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} diff --git a/types/test-cases/reducers/267-chat-turn-resume-noop-for-unresumable-error.json b/types/test-cases/reducers/267-chat-turn-resume-noop-for-unresumable-error.json new file mode 100644 index 000000000..e8c51a291 --- /dev/null +++ b/types/test-cases/reducers/267-chat-turn-resume-noop-for-unresumable-error.json @@ -0,0 +1,66 @@ +{ + "description": "chat/turnResume does nothing when the latest error is not resumable", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "fatal", + "message": "Cannot resume" + } + } + ], + "usage": null, + "state": "error" + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnResume", + "turnId": "turn-1" + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "fatal", + "message": "Cannot resume" + } + } + ], + "usage": null, + "state": "error" + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} diff --git a/types/test-cases/reducers/268-chat-turn-resume-preserves-errors-across-retries.json b/types/test-cases/reducers/268-chat-turn-resume-preserves-errors-across-retries.json new file mode 100644 index 000000000..894b79196 --- /dev/null +++ b/types/test-cases/reducers/268-chat-turn-resume-preserves-errors-across-retries.json @@ -0,0 +1,93 @@ +{ + "description": "a resumed turn preserves its prior error when it errors again", + "reducer": "chat", + "initial": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/error", + "turnId": "turn-1", + "duration": 1000, + "part": { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "First failure" + }, + "resumable": true + } + }, + { + "type": "chat/turnResume", + "turnId": "turn-1" + }, + { + "type": "chat/error", + "turnId": "turn-1", + "duration": 2000, + "part": { + "kind": "error", + "error": { + "errorType": "fatal", + "message": "Second failure" + } + } + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 2000, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "First failure" + }, + "resumable": true + }, + { + "kind": "error", + "error": { + "errorType": "fatal", + "message": "Second failure" + } + } + ], + "usage": null, + "state": "error" + } + ], + "activeTurn": null, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:03.000Z" + } +} diff --git a/types/test-cases/reducers/269-chat-turn-resume-completes-one-turn.json b/types/test-cases/reducers/269-chat-turn-resume-completes-one-turn.json new file mode 100644 index 000000000..631d50610 --- /dev/null +++ b/types/test-cases/reducers/269-chat-turn-resume-completes-one-turn.json @@ -0,0 +1,82 @@ +{ + "description": "a resumed turn completes as one turn and preserves its error", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 1000, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "resumable": true + } + ], + "usage": null, + "state": "error" + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnResume", + "turnId": "turn-1" + }, + { + "type": "chat/turnComplete", + "turnId": "turn-1", + "duration": 2000 + }, + { + "type": "chat/turnResume", + "turnId": "turn-1" + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 2000, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "resumable": true + } + ], + "usage": null, + "state": "complete" + } + ], + "activeTurn": null, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:03.000Z" + } +} diff --git a/types/test-cases/reducers/270-chat-responsepart-cannot-append-error.json b/types/test-cases/reducers/270-chat-responsepart-cannot-append-error.json new file mode 100644 index 000000000..10d176f88 --- /dev/null +++ b/types/test-cases/reducers/270-chat-responsepart-cannot-append-error.json @@ -0,0 +1,55 @@ +{ + "description": "chat/responsePart cannot append an error without the atomic chat/error transition", + "reducer": "chat", + "initial": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/responsePart", + "turnId": "turn-1", + "part": { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + } + } + } + ], + "expected": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} diff --git a/types/test-cases/reducers/271-turn-end-normalizes-offset-and-rollover.json b/types/test-cases/reducers/271-turn-end-normalizes-offset-and-rollover.json index 41de405f9..90978d5cc 100644 --- a/types/test-cases/reducers/271-turn-end-normalizes-offset-and-rollover.json +++ b/types/test-cases/reducers/271-turn-end-normalizes-offset-and-rollover.json @@ -45,8 +45,7 @@ }, "responseParts": [], "usage": null, - "state": "complete", - "error": null + "state": "complete" } ], "activeTurn": null diff --git a/types/test-cases/round-trips/041-chat-error-part-discriminator.json b/types/test-cases/round-trips/041-chat-error-part-discriminator.json new file mode 100644 index 000000000..51b2c11fe --- /dev/null +++ b/types/test-cases/round-trips/041-chat-error-part-discriminator.json @@ -0,0 +1,34 @@ +{ + "name": "chat-error-part-discriminator", + "group": "A", + "description": "A chat/error action preserves the nested ErrorResponsePart kind discriminator required by the wire schema.", + "type": "StateAction", + "input": { + "type": "chat/error", + "turnId": "turn-1", + "duration": 1000, + "part": { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "resumable": true + } + }, + "acceptableOutputs": [ + { + "type": "chat/error", + "turnId": "turn-1", + "duration": 1000, + "part": { + "kind": "error", + "error": { + "errorType": "runtime", + "message": "Something broke" + }, + "resumable": true + } + } + ] +} diff --git a/types/version/registry.test.ts b/types/version/registry.test.ts index 381ce7cd8..35d618d62 100644 --- a/types/version/registry.test.ts +++ b/types/version/registry.test.ts @@ -14,10 +14,13 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + ACTION_INTRODUCED_IN, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, compareProtocolVersions, + isActionKnownToVersion, } from './registry.js'; +import { ActionType } from '../actions.js'; const SEMVER_RE = /^\d+\.\d+\.\d+$/; @@ -57,6 +60,17 @@ test('SUPPORTED_PROTOCOL_VERSIONS has no duplicates', () => { assert.equal(set.size, SUPPORTED_PROTOCOL_VERSIONS.length); }); +test('chat/turnResume is available starting in protocol 1.0.0', () => { + const action = { + type: ActionType.ChatTurnResume, + turnId: 'turn-1', + } as const; + + assert.equal(ACTION_INTRODUCED_IN[ActionType.ChatTurnResume], '1.0.0'); + assert.equal(isActionKnownToVersion(action, '0.8.0'), false); + assert.equal(isActionKnownToVersion(action, '1.0.0'), true); +}); + test('public package entry re-exports both protocol-version constants', async () => { const pkg = await import('../index.js'); assert.equal(pkg.PROTOCOL_VERSION, PROTOCOL_VERSION); diff --git a/types/version/registry.ts b/types/version/registry.ts index 6bdb8a02a..7dd1f9cf9 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -125,6 +125,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatTurnComplete]: '0.4.0', [ActionType.ChatTurnCancelled]: '0.4.0', [ActionType.ChatError]: '0.4.0', + [ActionType.ChatTurnResume]: '1.0.0', [ActionType.ChatActivityChanged]: '0.5.0', [ActionType.ChatWorkingDirectorySet]: '0.7.0', [ActionType.ChatWorkingDirectoryRemoved]: '0.7.0',