From 8d345c9ea168190aa8fcadfddf80af472527be73 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sat, 25 Jul 2026 16:11:53 -0700 Subject: [PATCH 1/4] chat: add failed turn resume Add a capability-gated action that reopens the most recent resumable failed turn without adding another message. Preserve the original turn identity, response, and usage while allowing providers to continue processing. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahptypes/actions.generated.go | 15 +++ clients/go/ahptypes/state.generated.go | 4 + .../generated/Actions.generated.kt | 14 +++ .../generated/State.generated.kt | 10 +- clients/rust/crates/ahp-types/src/actions.rs | 12 +++ clients/rust/crates/ahp-types/src/state.rs | 6 ++ .../Generated/Actions.generated.swift | 19 ++++ .../Generated/State.generated.swift | 11 ++- .../.changes/20260717-resume-failed-turn.json | 4 + docs/guide/actions.md | 2 + docs/specification/chat-channel.md | 2 + schema/actions.schema.json | 32 ++++++ schema/commands.schema.json | 29 ++++++ schema/errors.schema.json | 29 ++++++ schema/notifications.schema.json | 9 ++ schema/state.schema.json | 9 ++ scripts/generate-go.ts | 1 + scripts/generate-kotlin.ts | 1 + scripts/generate-rust.ts | 1 + scripts/generate-swift.ts | 1 + types/action-origin.generated.ts | 4 + types/channels-chat/actions.ts | 14 +++ types/channels-chat/reducer.ts | 32 ++++++ types/channels-root/state.ts | 10 +- types/common/actions.ts | 3 + types/common/state.ts | 2 + ...t-turnresumed-reopens-resumable-error.json | 80 +++++++++++++++ ...7-chat-turnresumed-completes-one-turn.json | 98 +++++++++++++++++++ ...hat-turnresumed-noop-with-active-turn.json | 54 ++++++++++ ...hat-turnresumed-noop-for-unknown-turn.json | 30 ++++++ ...t-turnresumed-noop-for-completed-turn.json | 56 +++++++++++ ...rnresumed-noop-for-nonresumable-error.json | 64 ++++++++++++ ...nresumed-can-resume-again-after-error.json | 74 ++++++++++++++ ...-turnresumed-noop-for-nonlatest-error.json | 86 ++++++++++++++++ types/version/registry.ts | 1 + 35 files changed, 813 insertions(+), 6 deletions(-) create mode 100644 docs/.changes/20260717-resume-failed-turn.json create mode 100644 types/test-cases/reducers/256-chat-turnresumed-reopens-resumable-error.json create mode 100644 types/test-cases/reducers/257-chat-turnresumed-completes-one-turn.json create mode 100644 types/test-cases/reducers/258-chat-turnresumed-noop-with-active-turn.json create mode 100644 types/test-cases/reducers/259-chat-turnresumed-noop-for-unknown-turn.json create mode 100644 types/test-cases/reducers/260-chat-turnresumed-noop-for-completed-turn.json create mode 100644 types/test-cases/reducers/261-chat-turnresumed-noop-for-nonresumable-error.json create mode 100644 types/test-cases/reducers/262-chat-turnresumed-can-resume-again-after-error.json create mode 100644 types/test-cases/reducers/263-chat-turnresumed-noop-for-nonlatest-error.json diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index 4071bd50..3f2c4d49 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -28,6 +28,7 @@ const ( ActionTypeSessionChatUpdated ActionType = "session/chatUpdated" ActionTypeSessionDefaultChatChanged ActionType = "session/defaultChatChanged" ActionTypeChatTurnStarted ActionType = "chat/turnStarted" + ActionTypeChatTurnResumed ActionType = "chat/turnResumed" ActionTypeChatDelta ActionType = "chat/delta" ActionTypeChatResponsePart ActionType = "chat/responsePart" ActionTypeChatToolCallStart ActionType = "chat/toolCallStart" @@ -234,6 +235,13 @@ type ChatTurnStartedAction struct { Meta map[string]json.RawMessage `json:"_meta,omitempty"` } +// Resumes a failed turn without adding another message. +type ChatTurnResumedAction struct { + Type ActionType `json:"type"` + // Identifier of the resumable failed turn. + TurnId string `json:"turnId"` +} + // Streaming text chunk from the assistant, appended to a specific response part. // // The server MUST first emit a `chat/responsePart` to create the target @@ -1501,6 +1509,7 @@ func (*SessionChatRemovedAction) isStateAction() {} func (*SessionChatUpdatedAction) isStateAction() {} func (*SessionDefaultChatChangedAction) isStateAction() {} func (*ChatTurnStartedAction) isStateAction() {} +func (*ChatTurnResumedAction) isStateAction() {} func (*ChatDeltaAction) isStateAction() {} func (*ChatResponsePartAction) isStateAction() {} func (*ChatToolCallStartAction) isStateAction() {} @@ -1651,6 +1660,12 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "chat/turnResumed": + var value ChatTurnResumedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value case "chat/delta": var value ChatDeltaAction if err := json.Unmarshal(data, &value); err != nil { diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index e0618669..f089961c 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -593,6 +593,8 @@ type AgentCapabilities struct { // set and MUST NOT set more than one entry in // {@link CreateSessionParams.workingDirectories}. MultipleWorkingDirectories *MultipleWorkingDirectoriesCapability `json:"multipleWorkingDirectories,omitempty"` + // The agent can resume a failed turn without adding another message. + ResumeTurn *json.RawMessage `json:"resumeTurn,omitempty"` } // Options for the {@link AgentCapabilities.multipleChats} capability. @@ -3183,6 +3185,8 @@ type ErrorInfo struct { Message string `json:"message"` // Stack trace Stack *string `json:"stack,omitempty"` + // Whether the failed operation can be resumed without adding new user input. + Resumable *bool `json:"resumable,omitempty"` // Additional provider-specific metadata for this error. // Clients MAY look for well-known optional keys here to provide enhanced UI // (e.g. a structured chat fetch error for richer, localized messaging). diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt index f696130b..0e1c5cd3 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt @@ -44,6 +44,8 @@ enum class ActionType { SESSION_DEFAULT_CHAT_CHANGED, @SerialName("chat/turnStarted") CHAT_TURN_STARTED, + @SerialName("chat/turnResumed") + CHAT_TURN_RESUMED, @SerialName("chat/delta") CHAT_DELTA, @SerialName("chat/responsePart") @@ -327,6 +329,15 @@ data class ChatTurnStartedAction( val meta: Map? = null ) +@Serializable +data class ChatTurnResumedAction( + val type: ActionType, + /** + * Identifier of the resumable failed turn. + */ + val turnId: String +) + @Serializable data class ChatDeltaAction( val type: ActionType, @@ -1546,6 +1557,7 @@ sealed interface StateAction @JvmInline value class StateActionSessionChatUpdated(val value: SessionChatUpdatedAction) : StateAction @JvmInline value class StateActionSessionDefaultChatChanged(val value: SessionDefaultChatChangedAction) : StateAction @JvmInline value class StateActionChatTurnStarted(val value: ChatTurnStartedAction) : StateAction +@JvmInline value class StateActionChatTurnResumed(val value: ChatTurnResumedAction) : StateAction @JvmInline value class StateActionChatDelta(val value: ChatDeltaAction) : StateAction @JvmInline value class StateActionChatResponsePart(val value: ChatResponsePartAction) : StateAction @JvmInline value class StateActionChatToolCallStart(val value: ChatToolCallStartAction) : StateAction @@ -1646,6 +1658,7 @@ internal object StateActionSerializer : KSerializer { "session/chatUpdated" -> StateActionSessionChatUpdated(input.json.decodeFromJsonElement(SessionChatUpdatedAction.serializer(), element)) "session/defaultChatChanged" -> StateActionSessionDefaultChatChanged(input.json.decodeFromJsonElement(SessionDefaultChatChangedAction.serializer(), element)) "chat/turnStarted" -> StateActionChatTurnStarted(input.json.decodeFromJsonElement(ChatTurnStartedAction.serializer(), element)) + "chat/turnResumed" -> StateActionChatTurnResumed(input.json.decodeFromJsonElement(ChatTurnResumedAction.serializer(), element)) "chat/delta" -> StateActionChatDelta(input.json.decodeFromJsonElement(ChatDeltaAction.serializer(), element)) "chat/responsePart" -> StateActionChatResponsePart(input.json.decodeFromJsonElement(ChatResponsePartAction.serializer(), element)) "chat/toolCallStart" -> StateActionChatToolCallStart(input.json.decodeFromJsonElement(ChatToolCallStartAction.serializer(), element)) @@ -1739,6 +1752,7 @@ internal object StateActionSerializer : KSerializer { is StateActionSessionChatUpdated -> output.json.encodeToJsonElement(SessionChatUpdatedAction.serializer(), value.value) is StateActionSessionDefaultChatChanged -> output.json.encodeToJsonElement(SessionDefaultChatChangedAction.serializer(), value.value) is StateActionChatTurnStarted -> output.json.encodeToJsonElement(ChatTurnStartedAction.serializer(), value.value) + is StateActionChatTurnResumed -> output.json.encodeToJsonElement(ChatTurnResumedAction.serializer(), value.value) is StateActionChatDelta -> output.json.encodeToJsonElement(ChatDeltaAction.serializer(), value.value) is StateActionChatResponsePart -> output.json.encodeToJsonElement(ChatResponsePartAction.serializer(), value.value) is StateActionChatToolCallStart -> output.json.encodeToJsonElement(ChatToolCallStartAction.serializer(), value.value) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt index 840fe331..b04e7e1f 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt @@ -962,7 +962,11 @@ data class AgentCapabilities( * set and MUST NOT set more than one entry in * {@link CreateSessionParams.workingDirectories}. */ - val multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = null + val multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = null, + /** + * The agent can resume a failed turn without adding another message. + */ + val resumeTurn: JsonElement? = null ) @Serializable @@ -4251,6 +4255,10 @@ data class ErrorInfo( * Stack trace */ val stack: String? = null, + /** + * Whether the failed operation can be resumed without adding new user input. + */ + val resumable: Boolean? = null, /** * Additional provider-specific metadata for this error. * Clients MAY look for well-known optional keys here to provide enhanced UI diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 8cd2c8f4..7240a614 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -46,6 +46,8 @@ pub enum ActionType { SessionDefaultChatChanged, #[serde(rename = "chat/turnStarted")] ChatTurnStarted, + #[serde(rename = "chat/turnResumed")] + ChatTurnResumed, #[serde(rename = "chat/delta")] ChatDelta, #[serde(rename = "chat/responsePart")] @@ -352,6 +354,14 @@ pub struct ChatTurnStartedAction { pub meta: Option, } +/// Resumes a failed turn without adding another message. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatTurnResumedAction { + /// Identifier of the resumable failed turn. + pub turn_id: String, +} + /// Streaming text chunk from the assistant, appended to a specific response part. /// /// The server MUST first emit a `chat/responsePart` to create the target @@ -1798,6 +1808,8 @@ pub enum StateAction { SessionDefaultChatChanged(SessionDefaultChatChangedAction), #[serde(rename = "chat/turnStarted")] ChatTurnStarted(ChatTurnStartedAction), + #[serde(rename = "chat/turnResumed")] + ChatTurnResumed(ChatTurnResumedAction), #[serde(rename = "chat/delta")] ChatDelta(ChatDeltaAction), #[serde(rename = "chat/responsePart")] diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index bd978751..7103702a 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -804,6 +804,9 @@ pub struct AgentCapabilities { /// {@link CreateSessionParams.workingDirectories}. #[serde(default, skip_serializing_if = "Option::is_none")] pub multiple_working_directories: Option, + /// The agent can resume a failed turn without adding another message. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resume_turn: Option, } /// Options for the {@link AgentCapabilities.multipleChats} capability. @@ -3837,6 +3840,9 @@ pub struct ErrorInfo { /// Stack trace #[serde(default, skip_serializing_if = "Option::is_none")] pub stack: Option, + /// Whether the failed operation can be resumed without adding new user input. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resumable: Option, /// Additional provider-specific metadata for this error. /// Clients MAY look for well-known optional keys here to provide enhanced UI /// (e.g. a structured chat fetch error for richer, localized messaging). diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 809dd13b..cb57b482 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -15,6 +15,7 @@ public enum ActionType: String, Codable, Sendable { case sessionChatUpdated = "session/chatUpdated" case sessionDefaultChatChanged = "session/defaultChatChanged" case chatTurnStarted = "chat/turnStarted" + case chatTurnResumed = "chat/turnResumed" case chatDelta = "chat/delta" case chatResponsePart = "chat/responsePart" case chatToolCallStart = "chat/toolCallStart" @@ -293,6 +294,20 @@ public struct ChatTurnStartedAction: Codable, Sendable { } } +public struct ChatTurnResumedAction: Codable, Sendable { + public var type: ActionType + /// Identifier of the resumable failed turn. + public var turnId: String + + public init( + type: ActionType, + turnId: String + ) { + self.type = type + self.turnId = turnId + } +} + public struct ChatDeltaAction: Codable, Sendable { public var type: ActionType /// Turn identifier @@ -2026,6 +2041,7 @@ public enum StateAction: Codable, Sendable { case sessionChatUpdated(SessionChatUpdatedAction) case sessionDefaultChatChanged(SessionDefaultChatChangedAction) case chatTurnStarted(ChatTurnStartedAction) + case chatTurnResumed(ChatTurnResumedAction) case chatDelta(ChatDeltaAction) case chatResponsePart(ChatResponsePartAction) case chatToolCallStart(ChatToolCallStartAction) @@ -2132,6 +2148,8 @@ public enum StateAction: Codable, Sendable { self = .sessionDefaultChatChanged(try SessionDefaultChatChangedAction(from: decoder)) case "chat/turnStarted": self = .chatTurnStarted(try ChatTurnStartedAction(from: decoder)) + case "chat/turnResumed": + self = .chatTurnResumed(try ChatTurnResumedAction(from: decoder)) case "chat/delta": self = .chatDelta(try ChatDeltaAction(from: decoder)) case "chat/responsePart": @@ -2300,6 +2318,7 @@ public enum StateAction: Codable, Sendable { case .sessionChatUpdated(let v): try v.encode(to: encoder) case .sessionDefaultChatChanged(let v): try v.encode(to: encoder) case .chatTurnStarted(let v): try v.encode(to: encoder) + case .chatTurnResumed(let v): try v.encode(to: encoder) case .chatDelta(let v): try v.encode(to: encoder) case .chatResponsePart(let v): try v.encode(to: encoder) case .chatToolCallStart(let v): try v.encode(to: encoder) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 515f9c4a..c07f8155 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -635,13 +635,17 @@ public struct AgentCapabilities: Codable, Sendable { /// set and MUST NOT set more than one entry in /// {@link CreateSessionParams.workingDirectories}. public var multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? + /// The agent can resume a failed turn without adding another message. + public var resumeTurn: AnyCodable? public init( multipleChats: MultipleChatsCapability? = nil, - multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = nil + multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = nil, + resumeTurn: AnyCodable? = nil ) { self.multipleChats = multipleChats self.multipleWorkingDirectories = multipleWorkingDirectories + self.resumeTurn = resumeTurn } } @@ -4741,6 +4745,8 @@ public struct ErrorInfo: Codable, Sendable { public var message: String /// Stack trace public var stack: String? + /// Whether the failed operation can be resumed without adding new user input. + public var resumable: Bool? /// Additional provider-specific metadata for this error. /// Clients MAY look for well-known optional keys here to provide enhanced UI /// (e.g. a structured chat fetch error for richer, localized messaging). @@ -4750,6 +4756,7 @@ public struct ErrorInfo: Codable, Sendable { case errorType case message case stack + case resumable case meta = "_meta" } @@ -4757,11 +4764,13 @@ public struct ErrorInfo: Codable, Sendable { errorType: String, message: String, stack: String? = nil, + resumable: Bool? = nil, meta: [String: AnyCodable]? = nil ) { self.errorType = errorType self.message = message self.stack = stack + self.resumable = resumable self.meta = meta } } diff --git a/docs/.changes/20260717-resume-failed-turn.json b/docs/.changes/20260717-resume-failed-turn.json new file mode 100644 index 00000000..94ef9cdf --- /dev/null +++ b/docs/.changes/20260717-resume-failed-turn.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "`chat/turnResumed`, the `resumeTurn` agent capability, and resumable turn errors let clients continue failed turns without adding another user message." +} diff --git a/docs/guide/actions.md b/docs/guide/actions.md index 8f08f3d0..aca8d9fe 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -50,6 +50,7 @@ When a client dispatches an action, the server applies it to the state and also | Type | Client-dispatchable? | When | |---|---|---| | `chat/turnStarted` | **Yes** | User sent a message; server starts processing | +| `chat/turnResumed` | **Yes** | The most recent resumable failed turn is reopened without another user message | | `chat/delta` | No | Streaming text chunk appended to a response part by `partId` | | `chat/responsePart` | No | New response part created (markdown, reasoning, content ref, tool call) | | `chat/reasoning` | No | Reasoning/thinking text appended to a reasoning part by `partId` | @@ -187,6 +188,7 @@ The client applies the action **optimistically** to its local state before sendi | Action | Server-side effect | |---|---| | `chat/turnStarted` | Begins agent processing for the new turn | +| `chat/turnResumed` | Continues agent processing for the most recent resumable failed turn without adding user input | | `chat/toolCallConfirmed` | Approves or denies a pending tool call; unblocks or cancels tool execution | | `chat/turnCancelled` | Aborts the in-progress turn | | `session/titleChanged` | Updates the session title (rename) | diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index a2c8d020..9ead36d0 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -170,6 +170,7 @@ the host already materialized when it accepted the containing message. Once a chat exists and its session is `lifecycle: 'ready'`, the chat accepts turns. The wire shape mirrors the legacy single-chat session shape: - The client dispatches `chat/turnStarted` to begin a turn. +- After the most recent turn ends in a resumable error, the client may dispatch `chat/turnResumed` to continue it without adding another message. - The server streams `chat/delta`, `chat/responsePart`, `chat/toolCallStart`, `chat/toolCallReady`, and related actions. - The client dispatches `chat/toolCallConfirmed` / `chat/toolCallResultConfirmed` to approve or deny tool calls, or `chat/turnCancelled` to abort. - The server dispatches `chat/turnComplete` or `chat/error` when the turn ends. @@ -224,6 +225,7 @@ When the server receives a client-dispatched action on this channel, it MUST val | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | Any action referencing a non-existent chat | Channel URI not found | Server MUST silently ignore the action (no echo) | | `chat/toolCallConfirmed` | Tool call not in `pending-confirmation` state | Server MUST reject the action | +| `chat/turnResumed` | The target is not the most recent turn, it is not failed with `error.resumable: true`, or another turn is active | Server MUST reject the action | | `chat/turnCancelled` | No active turn | Server MUST reject the action | | `chat/inputAnswerChanged` | No input request with matching `requestId` | Server SHOULD reject the action | | `chat/inputAnswerChanged` | `answer.state` requires a value but `answer.value` is absent, or `answer.value.kind` is missing the matching payload field | Server SHOULD reject the action | diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 9a046769..7158d9e0 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -711,6 +711,23 @@ "message" ] }, + "ChatTurnResumedAction": { + "type": "object", + "description": "Resumes a failed turn without adding another message.", + "properties": { + "type": { + "const": "chat/turnResumed" + }, + "turnId": { + "type": "string", + "description": "Identifier of the resumable failed turn." + } + }, + "required": [ + "type", + "turnId" + ] + }, "ChatDeltaAction": { "type": "object", "description": "Streaming text chunk from the assistant, appended to a specific response part.\n\nThe server MUST first emit a `chat/responsePart` to create the target\npart (markdown or reasoning), then use this action to append text to it.", @@ -2109,6 +2126,9 @@ { "$ref": "#/$defs/ChatTurnStartedAction" }, + { + "$ref": "#/$defs/ChatTurnResumedAction" + }, { "$ref": "#/$defs/ChatDeltaAction" }, @@ -2567,6 +2587,10 @@ "type": "string", "description": "Stack trace" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed operation can be resumed without adding new user input." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -2718,6 +2742,11 @@ "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary}\n(some backends pin their first directory as a fixed process root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." + }, + "resumeTurn": { + "type": "object", + "properties": {}, + "description": "The agent can resume a failed turn without adding another message." } } }, @@ -7604,6 +7633,9 @@ { "$ref": "#/$defs/ChatTurnStartedAction" }, + { + "$ref": "#/$defs/ChatTurnResumedAction" + }, { "$ref": "#/$defs/ChatDeltaAction" }, diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 775ee128..90f9f702 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -1730,6 +1730,10 @@ "type": "string", "description": "Stack trace" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed operation can be resumed without adding new user input." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -1881,6 +1885,11 @@ "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary}\n(some backends pin their first directory as a fixed process root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." + }, + "resumeTurn": { + "type": "object", + "properties": {}, + "description": "The agent can resume a failed turn without adding another message." } } }, @@ -6951,6 +6960,23 @@ "message" ] }, + "ChatTurnResumedAction": { + "type": "object", + "description": "Resumes a failed turn without adding another message.", + "properties": { + "type": { + "const": "chat/turnResumed" + }, + "turnId": { + "type": "string", + "description": "Identifier of the resumable failed turn." + } + }, + "required": [ + "type", + "turnId" + ] + }, "ChatDeltaAction": { "type": "object", "description": "Streaming text chunk from the assistant, appended to a specific response part.\n\nThe server MUST first emit a `chat/responsePart` to create the target\npart (markdown or reasoning), then use this action to append text to it.", @@ -8435,6 +8461,9 @@ { "$ref": "#/$defs/ChatTurnStartedAction" }, + { + "$ref": "#/$defs/ChatTurnResumedAction" + }, { "$ref": "#/$defs/ChatDeltaAction" }, diff --git a/schema/errors.schema.json b/schema/errors.schema.json index c770002f..9e6adcdc 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -466,6 +466,10 @@ "type": "string", "description": "Stack trace" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed operation can be resumed without adding new user input." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -617,6 +621,11 @@ "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary}\n(some backends pin their first directory as a fixed process root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." + }, + "resumeTurn": { + "type": "object", + "properties": {}, + "description": "The agent can resume a failed turn without adding another message." } } }, @@ -7030,6 +7039,9 @@ { "$ref": "#/$defs/ChatTurnStartedAction" }, + { + "$ref": "#/$defs/ChatTurnResumedAction" + }, { "$ref": "#/$defs/ChatDeltaAction" }, @@ -7944,6 +7956,23 @@ "message" ] }, + "ChatTurnResumedAction": { + "type": "object", + "description": "Resumes a failed turn without adding another message.", + "properties": { + "type": { + "const": "chat/turnResumed" + }, + "turnId": { + "type": "string", + "description": "Identifier of the resumable failed turn." + } + }, + "required": [ + "type", + "turnId" + ] + }, "ChatDeltaAction": { "type": "object", "description": "Streaming text chunk from the assistant, appended to a specific response part.\n\nThe server MUST first emit a `chat/responsePart` to create the target\npart (markdown or reasoning), then use this action to append text to it.", diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index a11260fd..f868fa33 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -629,6 +629,10 @@ "type": "string", "description": "Stack trace" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed operation can be resumed without adding new user input." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -780,6 +784,11 @@ "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary}\n(some backends pin their first directory as a fixed process root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." + }, + "resumeTurn": { + "type": "object", + "properties": {}, + "description": "The agent can resume a failed turn without adding another message." } } }, diff --git a/schema/state.schema.json b/schema/state.schema.json index 0d347e2e..f17de0ea 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -377,6 +377,10 @@ "type": "string", "description": "Stack trace" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed operation can be resumed without adding new user input." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -528,6 +532,11 @@ "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary}\n(some backends pin their first directory as a fixed process root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." + }, + "resumeTurn": { + "type": "object", + "properties": {}, + "description": "The agent can resume a failed turn without adding another message." } } }, diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 33d7009c..691a15ac 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -1341,6 +1341,7 @@ const ACTION_VARIANTS: { { type: 'session/chatUpdated', variantName: 'SessionChatUpdated', tsInterface: 'SessionChatUpdatedAction' }, { type: 'session/defaultChatChanged', variantName: 'SessionDefaultChatChanged', tsInterface: 'SessionDefaultChatChangedAction' }, { type: 'chat/turnStarted', variantName: 'ChatTurnStarted', tsInterface: 'ChatTurnStartedAction' }, + { type: 'chat/turnResumed', variantName: 'ChatTurnResumed', tsInterface: 'ChatTurnResumedAction' }, { type: 'chat/delta', variantName: 'ChatDelta', tsInterface: 'ChatDeltaAction' }, { type: 'chat/responsePart', variantName: 'ChatResponsePart', tsInterface: 'ChatResponsePartAction' }, { type: 'chat/toolCallStart', variantName: 'ChatToolCallStart', tsInterface: 'ChatToolCallStartAction' }, diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 4fad6f13..aab57967 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -1270,6 +1270,7 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'session/chatUpdated', caseName: 'SessionChatUpdated', tsInterface: 'SessionChatUpdatedAction' }, { type: 'session/defaultChatChanged', caseName: 'SessionDefaultChatChanged', tsInterface: 'SessionDefaultChatChangedAction' }, { type: 'chat/turnStarted', caseName: 'ChatTurnStarted', tsInterface: 'ChatTurnStartedAction' }, + { type: 'chat/turnResumed', caseName: 'ChatTurnResumed', tsInterface: 'ChatTurnResumedAction' }, { type: 'chat/delta', caseName: 'ChatDelta', tsInterface: 'ChatDeltaAction' }, { type: 'chat/responsePart', caseName: 'ChatResponsePart', tsInterface: 'ChatResponsePartAction' }, { type: 'chat/toolCallStart', caseName: 'ChatToolCallStart', tsInterface: 'ChatToolCallStartAction' }, diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index eaba7592..f021e6b4 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -1203,6 +1203,7 @@ const ACTION_VARIANTS: { { type: 'session/chatUpdated', variantName: 'SessionChatUpdated', tsInterface: 'SessionChatUpdatedAction' }, { type: 'session/defaultChatChanged', variantName: 'SessionDefaultChatChanged', tsInterface: 'SessionDefaultChatChangedAction' }, { type: 'chat/turnStarted', variantName: 'ChatTurnStarted', tsInterface: 'ChatTurnStartedAction' }, + { type: 'chat/turnResumed', variantName: 'ChatTurnResumed', tsInterface: 'ChatTurnResumedAction' }, { type: 'chat/delta', variantName: 'ChatDelta', tsInterface: 'ChatDeltaAction' }, { type: 'chat/responsePart', variantName: 'ChatResponsePart', tsInterface: 'ChatResponsePartAction' }, { type: 'chat/toolCallStart', variantName: 'ChatToolCallStart', tsInterface: 'ChatToolCallStartAction' }, diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 88d307d6..e1da37d8 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -1181,6 +1181,7 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'session/chatUpdated', caseName: 'sessionChatUpdated', tsInterface: 'SessionChatUpdatedAction' }, { type: 'session/defaultChatChanged', caseName: 'sessionDefaultChatChanged', tsInterface: 'SessionDefaultChatChangedAction' }, { type: 'chat/turnStarted', caseName: 'chatTurnStarted', tsInterface: 'ChatTurnStartedAction' }, + { type: 'chat/turnResumed', caseName: 'chatTurnResumed', tsInterface: 'ChatTurnResumedAction' }, { type: 'chat/delta', caseName: 'chatDelta', tsInterface: 'ChatDeltaAction' }, { type: 'chat/responsePart', caseName: 'chatResponsePart', tsInterface: 'ChatResponsePartAction' }, { type: 'chat/toolCallStart', caseName: 'chatToolCallStart', tsInterface: 'ChatToolCallStartAction' }, diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index 92cffe48..d70dec75 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -35,6 +35,7 @@ import type { SessionConfigChangedAction, SessionMetaChangedAction, ChatTurnStartedAction, + ChatTurnResumedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, @@ -183,6 +184,7 @@ export type ServerSessionAction = /** Union of all chat-scoped actions. */ export type ChatAction = | ChatTurnStartedAction + | ChatTurnResumedAction | ChatDeltaAction | ChatResponsePartAction | ChatToolCallStartAction @@ -216,6 +218,7 @@ export type ChatAction = /** Union of chat actions that clients may dispatch. */ export type ClientChatAction = | ChatTurnStartedAction + | ChatTurnResumedAction | ChatToolCallConfirmedAction | ChatToolCallCompleteAction | ChatToolCallResultConfirmedAction @@ -389,6 +392,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.SessionConfigChanged]: true, [ActionType.SessionMetaChanged]: false, [ActionType.ChatTurnStarted]: true, + [ActionType.ChatTurnResumed]: true, [ActionType.ChatDelta]: false, [ActionType.ChatResponsePart]: false, [ActionType.ChatToolCallStart]: false, diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index 35307ec1..bb671c64 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -85,6 +85,19 @@ export interface ChatTurnStartedAction { _meta?: Record; } +/** + * Resumes a failed turn without adding another message. + * + * @category Chat Actions + * @version 1 + * @clientDispatchable + */ +export interface ChatTurnResumedAction { + type: ActionType.ChatTurnResumed; + /** Identifier of the resumable failed turn. */ + turnId: string; +} + /** * Streaming text chunk from the assistant, appended to a specific response part. * @@ -800,6 +813,7 @@ export interface ChatInputCompletedAction { export type ChatAction = | ChatTurnStartedAction + | ChatTurnResumedAction | ChatDeltaAction | ChatResponsePartAction | ChatToolCallStartAction diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index 332eaaec..ce5b7f69 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -373,6 +373,38 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st return next; } + case ActionType.ChatTurnResumed: { + if (state.activeTurn) { + return state; + } + const turnIndex = state.turns.length - 1; + const turn = state.turns[turnIndex]; + if (!turn || turn.id !== action.turnId) { + return state; + } + if (turn.state !== TurnState.Error || turn.error?.resumable !== true) { + return state; + } + const turns = state.turns.slice(); + turns.splice(turnIndex, 1); + const next: ChatState = { + ...state, + turns, + activeTurn: { + id: turn.id, + startedAt: turn.startedAt ?? state.modifiedAt, + message: turn.message, + responseParts: turn.responseParts, + usage: turn.usage, + }, + }; + return { + ...next, + status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), + modifiedAt: new Date(Date.now()).toISOString(), + }; + } + case ActionType.ChatDelta: return updateResponsePart(state, action.turnId, action.partId, part => { if (part.kind === ResponsePartKind.Markdown) { diff --git a/types/channels-root/state.ts b/types/channels-root/state.ts index eaceed68..0aafa8c5 100644 --- a/types/channels-root/state.ts +++ b/types/channels-root/state.ts @@ -110,10 +110,10 @@ export interface AgentCapabilities { * source-based creation; set {@link MultipleChatsCapability.fork} or * {@link MultipleChatsCapability.sideChat} to allow the corresponding mode. */ - multipleChats?: MultipleChatsCapability; - /** - * The session's agent can be granted tool access to more than one working - * directory. The directories are treated as equal peers except where the + multipleChats?: MultipleChatsCapability; + /** + * The session's agent can be granted tool access to more than one working + * directory. The directories are treated as equal peers except where the * agent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary} * (some backends pin their first directory as a fixed process root). * @@ -122,6 +122,8 @@ export interface AgentCapabilities { * {@link CreateSessionParams.workingDirectories}. */ multipleWorkingDirectories?: MultipleWorkingDirectoriesCapability; + /** The agent can resume a failed turn without adding another message. */ + resumeTurn?: {}; } /** diff --git a/types/common/actions.ts b/types/common/actions.ts index d0716412..6b09b63b 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -47,6 +47,7 @@ import type { import type { ChatTurnStartedAction, + ChatTurnResumedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, @@ -131,6 +132,7 @@ export const enum ActionType { SessionChatUpdated = 'session/chatUpdated', SessionDefaultChatChanged = 'session/defaultChatChanged', ChatTurnStarted = 'chat/turnStarted', + ChatTurnResumed = 'chat/turnResumed', ChatDelta = 'chat/delta', ChatResponsePart = 'chat/responsePart', ChatToolCallStart = 'chat/toolCallStart', @@ -275,6 +277,7 @@ export type StateAction = | SessionConfigChangedAction | SessionMetaChangedAction | ChatTurnStartedAction + | ChatTurnResumedAction | ChatDeltaAction | ChatResponsePartAction | ChatToolCallStartAction diff --git a/types/common/state.ts b/types/common/state.ts index be6c2e16..0660f4ec 100644 --- a/types/common/state.ts +++ b/types/common/state.ts @@ -315,6 +315,8 @@ export interface ErrorInfo { message: string; /** Stack trace */ stack?: string; + /** Whether the failed operation can be resumed without adding new user input. */ + resumable?: boolean; /** * Additional provider-specific metadata for this error. * Clients MAY look for well-known optional keys here to provide enhanced UI diff --git a/types/test-cases/reducers/256-chat-turnresumed-reopens-resumable-error.json b/types/test-cases/reducers/256-chat-turnresumed-reopens-resumable-error.json new file mode 100644 index 00000000..78fa9da7 --- /dev/null +++ b/types/test-cases/reducers/256-chat-turnresumed-reopens-resumable-error.json @@ -0,0 +1,80 @@ +{ + "description": "chat/turnResumed reopens a resumable failed turn with its existing response", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 1000, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "markdown", + "id": "md-1", + "content": "Partial" + } + ], + "usage": { + "inputTokens": 10, + "outputTokens": 2 + }, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Request failed", + "resumable": true + } + } + ] + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 8, + "modifiedAt": "1970-01-01T00:00:09.999Z", + "origin": { + "kind": "user" + }, + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "markdown", + "id": "md-1", + "content": "Partial" + } + ], + "usage": { + "inputTokens": 10, + "outputTokens": 2 + } + } + } +} diff --git a/types/test-cases/reducers/257-chat-turnresumed-completes-one-turn.json b/types/test-cases/reducers/257-chat-turnresumed-completes-one-turn.json new file mode 100644 index 00000000..708205f8 --- /dev/null +++ b/types/test-cases/reducers/257-chat-turnresumed-completes-one-turn.json @@ -0,0 +1,98 @@ +{ + "description": "a resumed turn completes as one durable turn", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 1000, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "markdown", + "id": "md-1", + "content": "Partial" + } + ], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Request failed", + "resumable": true + } + } + ] + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + }, + { + "type": "chat/responsePart", + "turnId": "turn-1", + "part": { + "kind": "markdown", + "id": "md-2", + "content": "Done" + } + }, + { + "type": "chat/turnComplete", + "turnId": "turn-1", + "duration": 2000 + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:09.999Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 2000, + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "markdown", + "id": "md-1", + "content": "Partial" + }, + { + "kind": "markdown", + "id": "md-2", + "content": "Done" + } + ], + "usage": null, + "state": "complete", + "error": null + } + ], + "activeTurn": null + } +} diff --git a/types/test-cases/reducers/258-chat-turnresumed-noop-with-active-turn.json b/types/test-cases/reducers/258-chat-turnresumed-noop-with-active-turn.json new file mode 100644 index 00000000..825c7aa3 --- /dev/null +++ b/types/test-cases/reducers/258-chat-turnresumed-noop-with-active-turn.json @@ -0,0 +1,54 @@ +{ + "description": "chat/turnResumed is a no-op while another turn is active", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [], + "activeTurn": { + "id": "turn-active", + "startedAt": "1970-01-01T00:00:02.000Z", + "message": { + "text": "Running", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + } + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [], + "activeTurn": { + "id": "turn-active", + "startedAt": "1970-01-01T00:00:02.000Z", + "message": { + "text": "Running", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + } + } +} diff --git a/types/test-cases/reducers/259-chat-turnresumed-noop-for-unknown-turn.json b/types/test-cases/reducers/259-chat-turnresumed-noop-for-unknown-turn.json new file mode 100644 index 00000000..ac31d782 --- /dev/null +++ b/types/test-cases/reducers/259-chat-turnresumed-noop-for-unknown-turn.json @@ -0,0 +1,30 @@ +{ + "description": "chat/turnResumed is a no-op for an unknown turn", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [] + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [] + } +} diff --git a/types/test-cases/reducers/260-chat-turnresumed-noop-for-completed-turn.json b/types/test-cases/reducers/260-chat-turnresumed-noop-for-completed-turn.json new file mode 100644 index 00000000..83e20470 --- /dev/null +++ b/types/test-cases/reducers/260-chat-turnresumed-noop-for-completed-turn.json @@ -0,0 +1,56 @@ +{ + "description": "chat/turnResumed is a no-op for a completed turn", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ] + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete" + } + ] + } +} diff --git a/types/test-cases/reducers/261-chat-turnresumed-noop-for-nonresumable-error.json b/types/test-cases/reducers/261-chat-turnresumed-noop-for-nonresumable-error.json new file mode 100644 index 00000000..ca8fd2f5 --- /dev/null +++ b/types/test-cases/reducers/261-chat-turnresumed-noop-for-nonresumable-error.json @@ -0,0 +1,64 @@ +{ + "description": "chat/turnResumed is a no-op for a non-resumable error", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Request failed" + } + } + ] + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Request failed" + } + } + ] + } +} diff --git a/types/test-cases/reducers/262-chat-turnresumed-can-resume-again-after-error.json b/types/test-cases/reducers/262-chat-turnresumed-can-resume-again-after-error.json new file mode 100644 index 00000000..7840e5de --- /dev/null +++ b/types/test-cases/reducers/262-chat-turnresumed-can-resume-again-after-error.json @@ -0,0 +1,74 @@ +{ + "description": "a resumed turn can be resumed again after another resumable error", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "First failure", + "resumable": true + } + } + ] + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + }, + { + "type": "chat/error", + "turnId": "turn-1", + "duration": 1000, + "error": { + "errorType": "runtime", + "message": "Second failure", + "resumable": true + } + }, + { + "type": "chat/turnResumed", + "turnId": "turn-1" + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 8, + "modifiedAt": "1970-01-01T00:00:09.999Z", + "origin": { + "kind": "user" + }, + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:02.000Z", + "message": { + "text": "Hello", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + } + } +} diff --git a/types/test-cases/reducers/263-chat-turnresumed-noop-for-nonlatest-error.json b/types/test-cases/reducers/263-chat-turnresumed-noop-for-nonlatest-error.json new file mode 100644 index 00000000..1bc4673a --- /dev/null +++ b/types/test-cases/reducers/263-chat-turnresumed-noop-for-nonlatest-error.json @@ -0,0 +1,86 @@ +{ + "description": "chat/turnResumed is a no-op for a resumable error that is not the most recent turn", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:04.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "state": "error", + "error": { + "errorType": "runtime", + "message": "Request failed", + "resumable": true + } + }, + { + "id": "turn-2", + "message": { + "text": "Second", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "state": "complete" + } + ] + }, + "actions": [ + { + "type": "chat/turnResumed", + "turnId": "turn-1" + } + ], + "expected": { + "resource": "ahp-chat:/c1", + "title": "Chat 1", + "status": 1, + "modifiedAt": "1970-01-01T00:00:04.000Z", + "origin": { + "kind": "user" + }, + "turns": [ + { + "id": "turn-1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "state": "error", + "error": { + "errorType": "runtime", + "message": "Request failed", + "resumable": true + } + }, + { + "id": "turn-2", + "message": { + "text": "Second", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "state": "complete" + } + ] + } +} diff --git a/types/version/registry.ts b/types/version/registry.ts index e2ea8314..4259143d 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -108,6 +108,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.SessionConfigChanged]: '0.1.0', [ActionType.SessionMetaChanged]: '0.1.0', [ActionType.ChatTurnStarted]: '0.4.0', + [ActionType.ChatTurnResumed]: '0.7.0', [ActionType.ChatDelta]: '0.4.0', [ActionType.ChatResponsePart]: '0.4.0', [ActionType.ChatToolCallStart]: '0.4.0', From 279a206a914379b5fa445c762d2bbed374eb3c37 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 29 Jul 2026 17:43:01 -0700 Subject: [PATCH 2/4] clients: resume failed turns in reducers Keep the Go, Kotlin, Rust, and Swift reducers aligned with the canonical TypeScript reducer and shared fixtures. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahp/reducers.go | 31 +++++++++++++++++++ .../microsoft/agenthostprotocol/Reducers.kt | 29 +++++++++++++++++ clients/rust/crates/ahp/src/reducers.rs | 31 ++++++++++++++++++- .../Sources/AgentHostProtocol/Reducers.swift | 21 +++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 978608c9..7c145831 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -491,6 +491,8 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R switch a := action.Value.(type) { case *ahptypes.ChatTurnStartedAction: return applyTurnStarted(state, a) + case *ahptypes.ChatTurnResumedAction: + return applyTurnResumed(state, a) case *ahptypes.ChatDeltaAction: return updateResponsePart(state, a.TurnId, a.PartId, func(p *ahptypes.ResponsePart) { if m, ok := p.Value.(*ahptypes.MarkdownResponsePart); ok { @@ -1020,6 +1022,35 @@ func applyTurnStarted(state *ahptypes.ChatState, a *ahptypes.ChatTurnStartedActi return ReduceOutcomeApplied } +func applyTurnResumed(state *ahptypes.ChatState, a *ahptypes.ChatTurnResumedAction) ReduceOutcome { + if state.ActiveTurn != nil || len(state.Turns) == 0 { + return ReduceOutcomeNoOp + } + turnIndex := len(state.Turns) - 1 + turn := state.Turns[turnIndex] + if turn.Id != a.TurnId || turn.State != ahptypes.TurnStateError || + turn.Error == nil || turn.Error.Resumable == nil || !*turn.Error.Resumable { + return ReduceOutcomeNoOp + } + + startedAt := state.ModifiedAt + if turn.StartedAt != nil { + startedAt = *turn.StartedAt + } + state.Turns = state.Turns[:turnIndex] + state.ActiveTurn = &ahptypes.ActiveTurn{ + Id: turn.Id, + StartedAt: startedAt, + Message: turn.Message, + ResponseParts: turn.ResponseParts, + Usage: turn.Usage, + } + state.Status = summaryStatus(state, nil) + state.ModifiedAt = nowISOString() + state.Status = withStatusFlag(state.Status, ahptypes.SessionStatusIsRead, false) + return ReduceOutcomeApplied +} + func applyToolCallDelta(state *ahptypes.ChatState, a *ahptypes.ChatToolCallDeltaAction) ReduceOutcome { return updateToolCall(state, a.TurnId, a.ToolCallId, func(tc ahptypes.ToolCallState) ahptypes.ToolCallState { s, ok := tc.Value.(*ahptypes.ToolCallStreamingState) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt index b62d7a61..d6b71a8a 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -844,6 +844,35 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when } } + is StateActionChatTurnResumed -> { + val a = action.value + val turn = state.turns.lastOrNull() + if ( + state.activeTurn != null || + turn == null || + turn.id != a.turnId || + turn.state != TurnState.ERROR || + turn.error?.resumable != true + ) { + state + } else { + val withTurn = state.copy( + turns = state.turns.dropLast(1), + activeTurn = ActiveTurn( + id = turn.id, + startedAt = turn.startedAt ?: state.modifiedAt, + message = turn.message, + responseParts = turn.responseParts, + usage = turn.usage, + ), + ) + withTurn.copy( + status = withStatusFlag(chatSummaryStatus(withTurn), SessionStatus.IS_READ, false), + modifiedAt = nowIsoString(), + ) + } + } + is StateActionChatDelta -> { val a = action.value updateResponsePart(state, a.turnId, a.partId) { part -> diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 59c62518..a8b86215 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -55,7 +55,7 @@ use ahp_types::actions::{ ChatInputAnswerChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatToolCallCompleteAction, ChatToolCallConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallResultConfirmedAction, - ChatTurnStartedAction, StateAction, + ChatTurnResumedAction, ChatTurnStartedAction, StateAction, }; use ahp_types::state::{ ActiveTurn, AnnotationsState, ChangesetOperationStatus, ChangesetState, ChangesetStatus, @@ -943,6 +943,7 @@ fn update_mcp_server_customization_state( pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> ReduceOutcome { match action { StateAction::ChatTurnStarted(a) => apply_turn_started(state, a), + StateAction::ChatTurnResumed(a) => apply_turn_resumed(state, a), StateAction::ChatDelta(a) => update_response_part(state, &a.turn_id, &a.part_id, |p| { if let ResponsePart::Markdown(m) = p { m.content.push_str(&a.content); @@ -1236,6 +1237,34 @@ fn apply_turn_started(state: &mut ChatState, a: &ChatTurnStartedAction) -> Reduc ReduceOutcome::Applied } +fn apply_turn_resumed(state: &mut ChatState, a: &ChatTurnResumedAction) -> ReduceOutcome { + if state.active_turn.is_some() { + return ReduceOutcome::NoOp; + } + let Some(turn) = state.turns.last() else { + return ReduceOutcome::NoOp; + }; + if turn.id != a.turn_id + || turn.state != TurnState::Error + || turn.error.as_ref().and_then(|error| error.resumable) != Some(true) + { + return ReduceOutcome::NoOp; + } + + let turn = state.turns.pop().expect("last turn must exist"); + state.active_turn = Some(ActiveTurn { + id: turn.id, + started_at: turn.started_at.unwrap_or_else(|| state.modified_at.clone()), + message: turn.message, + response_parts: turn.response_parts, + usage: turn.usage, + }); + state.status = summary_status(state, None); + touch_chat_modified(state); + state.status = with_status_flag(state.status, SessionStatus::IsRead, false); + ReduceOutcome::Applied +} + fn apply_tool_call_delta(state: &mut ChatState, a: &ChatToolCallDeltaAction) -> ReduceOutcome { update_tool_call(state, &a.turn_id, &a.tool_call_id, |tc| match tc { ToolCallState::Streaming(mut s) => { diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index fc0f03fb..f239d88a 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -139,6 +139,27 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { next.status = withStatusFlag(chatSummaryStatus(next), .isRead, false) return next + case .chatTurnResumed(let a): + guard state.activeTurn == nil, + let turn = state.turns.last, + turn.id == a.turnId, + turn.state == .error, + turn.error?.resumable == true else { + return state + } + var next = state + next.turns.removeLast() + next.activeTurn = ActiveTurn( + id: turn.id, + startedAt: turn.startedAt ?? state.modifiedAt, + message: turn.message, + responseParts: turn.responseParts, + usage: turn.usage + ) + next.modifiedAt = currentTimestamp() + next.status = withStatusFlag(chatSummaryStatus(next), .isRead, false) + return next + case .chatDelta(let a): return updateResponsePart(state: state, turnId: a.turnId, partId: a.partId) { part in guard case .markdown(var md) = part else { return part } From af75d9eb1ad95d4e169cb44391b43e30b06d4f27 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 29 Jul 2026 17:43:39 -0700 Subject: [PATCH 3/4] chat: align capability formatting (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- types/channels-root/state.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/types/channels-root/state.ts b/types/channels-root/state.ts index 0aafa8c5..30a6bc00 100644 --- a/types/channels-root/state.ts +++ b/types/channels-root/state.ts @@ -110,10 +110,10 @@ export interface AgentCapabilities { * source-based creation; set {@link MultipleChatsCapability.fork} or * {@link MultipleChatsCapability.sideChat} to allow the corresponding mode. */ - multipleChats?: MultipleChatsCapability; - /** - * The session's agent can be granted tool access to more than one working - * directory. The directories are treated as equal peers except where the + multipleChats?: MultipleChatsCapability; + /** + * The session's agent can be granted tool access to more than one working + * directory. The directories are treated as equal peers except where the * agent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary} * (some backends pin their first directory as a fixed process root). * From 2de183d8878a9d8ffd5c39242cc2dcecc958b474 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 5 Aug 2026 15:32:44 -0700 Subject: [PATCH 4/4] chat: scope resume eligibility to turns Keep failed-turn resume state on chat turns instead of generic errors, remove the redundant static agent capability, and register the new action for AHP 0.8. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahp/reducers.go | 11 ++++++----- clients/go/ahptypes/actions.generated.go | 2 ++ clients/go/ahptypes/state.generated.go | 6 ++---- .../com/microsoft/agenthostprotocol/Reducers.kt | 6 ++++-- .../generated/Actions.generated.kt | 4 ++++ .../generated/State.generated.kt | 16 ++++++---------- clients/rust/crates/ahp-types/src/actions.rs | 3 +++ clients/rust/crates/ahp-types/src/state.rs | 9 +++------ clients/rust/crates/ahp/src/reducers.rs | 10 ++++++---- .../Generated/Actions.generated.swift | 5 +++++ .../Generated/State.generated.swift | 17 ++++++----------- .../Sources/AgentHostProtocol/Reducers.swift | 10 ++++++---- docs/.changes/20260717-resume-failed-turn.json | 2 +- docs/specification/chat-channel.md | 2 +- schema/actions.schema.json | 17 ++++++++--------- schema/commands.schema.json | 17 ++++++++--------- schema/errors.schema.json | 17 ++++++++--------- schema/notifications.schema.json | 13 ++++--------- schema/state.schema.json | 13 ++++--------- types/channels-chat/actions.ts | 2 ++ types/channels-chat/reducer.ts | 6 ++++-- types/channels-chat/state.ts | 2 ++ types/channels-root/state.ts | 2 -- types/common/state.ts | 2 -- ...hat-turnresumed-reopens-resumable-error.json | 6 +++--- ...257-chat-turnresumed-completes-one-turn.json | 6 +++--- ...urnresumed-can-resume-again-after-error.json | 12 ++++++------ ...at-turnresumed-noop-for-nonlatest-error.json | 12 ++++++------ types/version/registry.ts | 2 +- 29 files changed, 114 insertions(+), 118 deletions(-) diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index bd258298..9dafb92e 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -215,7 +215,7 @@ func touchChatModified(state *ahptypes.ChatState) { // ─── Active-turn helpers ─────────────────────────────────────────────── -func endTurn(state *ahptypes.ChatState, turnID string, duration int64, turnState ahptypes.TurnState, terminalStatus *ahptypes.SessionStatus, errInfo *ahptypes.ErrorInfo) ReduceOutcome { +func endTurn(state *ahptypes.ChatState, turnID string, duration int64, turnState ahptypes.TurnState, terminalStatus *ahptypes.SessionStatus, errInfo *ahptypes.ErrorInfo, resumable *bool) ReduceOutcome { if state.ActiveTurn == nil || state.ActiveTurn.Id != turnID { return ReduceOutcomeNoOp } @@ -269,6 +269,7 @@ func endTurn(state *ahptypes.ChatState, turnID string, duration int64, turnState Usage: active.Usage, State: turnState, Error: errInfo, + Resumable: resumable, } state.Turns = append(state.Turns, turn) @@ -520,13 +521,13 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R state.ActiveTurn.ResponseParts = append(state.ActiveTurn.ResponseParts, a.Part) return ReduceOutcomeApplied case *ahptypes.ChatTurnCompleteAction: - return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateComplete, nil, nil) + return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateComplete, nil, nil, nil) case *ahptypes.ChatTurnCancelledAction: - return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateCancelled, nil, nil) + return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateCancelled, nil, nil, nil) case *ahptypes.ChatErrorAction: errCopy := a.Error errStatus := ahptypes.SessionStatusError - return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateError, &errStatus, &errCopy) + return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateError, &errStatus, &errCopy, a.Resumable) case *ahptypes.ChatActivityChangedAction: state.Activity = a.Activity return ReduceOutcomeApplied @@ -1043,7 +1044,7 @@ func applyTurnResumed(state *ahptypes.ChatState, a *ahptypes.ChatTurnResumedActi turnIndex := len(state.Turns) - 1 turn := state.Turns[turnIndex] if turn.Id != a.TurnId || turn.State != ahptypes.TurnStateError || - turn.Error == nil || turn.Error.Resumable == nil || !*turn.Error.Resumable { + turn.Resumable == nil || !*turn.Resumable { return ReduceOutcomeNoOp } diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index 15e1b6e1..29f14118 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -599,6 +599,8 @@ type ChatErrorAction struct { Duration int64 `json:"duration"` // Error details Error ErrorInfo `json:"error"` + // Whether the failed turn can be resumed without adding another message. + Resumable *bool `json:"resumable,omitempty"` // Additional provider-specific metadata for this action. // // Clients MAY look for well-known keys here to provide enhanced UI, and diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 267d9869..61ad4c0d 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -593,8 +593,6 @@ type AgentCapabilities struct { // set and MUST NOT set more than one entry in // {@link CreateSessionParams.workingDirectories}. MultipleWorkingDirectories *MultipleWorkingDirectoriesCapability `json:"multipleWorkingDirectories,omitempty"` - // The agent can resume a failed turn without adding another message. - ResumeTurn *json.RawMessage `json:"resumeTurn,omitempty"` } // Options for the {@link AgentCapabilities.multipleChats} capability. @@ -1289,6 +1287,8 @@ type Turn struct { State TurnState `json:"state"` // Error details if state is `'error'` Error *ErrorInfo `json:"error,omitempty"` + // Whether this failed turn can be resumed without adding another message. + Resumable *bool `json:"resumable,omitempty"` } // An in-progress turn — the assistant is actively streaming. @@ -3223,8 +3223,6 @@ type ErrorInfo struct { Message string `json:"message"` // Stack trace Stack *string `json:"stack,omitempty"` - // Whether the failed operation can be resumed without adding new user input. - Resumable *bool `json:"resumable,omitempty"` // Additional provider-specific metadata for this error. // Clients MAY look for well-known optional keys here to provide enhanced UI // (e.g. a structured chat fetch error for richer, localized messaging). diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt index 1d649fc6..138aa4dc 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -379,6 +379,7 @@ private fun endTurn( turnState: TurnState, terminalStatus: SessionStatus? = null, error: ErrorInfo? = null, + resumable: Boolean? = null, ): ChatState { val active = state.activeTurn ?: return state if (active.id != turnId) return state @@ -434,6 +435,7 @@ private fun endTurn( usage = active.usage, state = turnState, error = error, + resumable = resumable, ) val withoutTurn = state.copy( @@ -857,7 +859,7 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when turn == null || turn.id != a.turnId || turn.state != TurnState.ERROR || - turn.error?.resumable != true + turn.resumable != true ) { state } else { @@ -908,7 +910,7 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when endTurn(state, action.value.turnId, action.value.duration, TurnState.CANCELLED) is StateActionChatError -> - endTurn(state, action.value.turnId, action.value.duration, TurnState.ERROR, SessionStatus.ERROR, action.value.error) + endTurn(state, action.value.turnId, action.value.duration, TurnState.ERROR, SessionStatus.ERROR, action.value.error, action.value.resumable) is StateActionChatActivityChanged -> state.copy(activity = action.value.activity) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt index 473c80b6..c941d243 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt @@ -765,6 +765,10 @@ data class ChatErrorAction( * Error details */ val error: ErrorInfo, + /** + * Whether the failed turn can be resumed without adding another message. + */ + val resumable: Boolean? = null, /** * Additional provider-specific metadata for this action. * 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 d25e0a9b..40964098 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt @@ -962,11 +962,7 @@ data class AgentCapabilities( * set and MUST NOT set more than one entry in * {@link CreateSessionParams.workingDirectories}. */ - val multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = null, - /** - * The agent can resume a failed turn without adding another message. - */ - val resumeTurn: JsonElement? = null + val multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = null ) @Serializable @@ -1738,7 +1734,11 @@ data class Turn( /** * Error details if state is `'error'` */ - val error: ErrorInfo? = null + val error: ErrorInfo? = null, + /** + * Whether this failed turn can be resumed without adding another message. + */ + val resumable: Boolean? = null ) @Serializable @@ -4288,10 +4288,6 @@ data class ErrorInfo( * Stack trace */ val stack: String? = null, - /** - * Whether the failed operation can be resumed without adding new user input. - */ - val resumable: Boolean? = null, /** * Additional provider-specific metadata for this error. * Clients MAY look for well-known optional keys here to provide enhanced UI diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 817e74c8..c48bc2a8 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -773,6 +773,9 @@ pub struct ChatErrorAction { pub duration: i64, /// Error details pub error: ErrorInfo, + /// Whether the failed turn can be resumed without adding another message. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resumable: Option, /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 24e70adb..44533099 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -804,9 +804,6 @@ pub struct AgentCapabilities { /// {@link CreateSessionParams.workingDirectories}. #[serde(default, skip_serializing_if = "Option::is_none")] pub multiple_working_directories: Option, - /// The agent can resume a failed turn without adding another message. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resume_turn: Option, } /// Options for the {@link AgentCapabilities.multipleChats} capability. @@ -1622,6 +1619,9 @@ pub struct Turn { /// Error details if state is `'error'` #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, + /// Whether this failed turn can be resumed without adding another message. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resumable: Option, } /// An in-progress turn — the assistant is actively streaming. @@ -3878,9 +3878,6 @@ pub struct ErrorInfo { /// Stack trace #[serde(default, skip_serializing_if = "Option::is_none")] pub stack: Option, - /// Whether the failed operation can be resumed without adding new user input. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resumable: Option, /// Additional provider-specific metadata for this error. /// Clients MAY look for well-known optional keys here to provide enhanced UI /// (e.g. a structured chat fetch error for richer, localized messaging). diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index a1dfb1b9..2f7e30fb 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -341,6 +341,7 @@ fn end_turn( turn_state: TurnState, terminal_status: Option, error: Option, + resumable: Option, ) -> ReduceOutcome { let Some(active) = state.active_turn.as_ref() else { return ReduceOutcome::NoOp; @@ -409,6 +410,7 @@ fn end_turn( usage: active.usage, state: turn_state, error, + resumable, }; state.turns.push(turn); @@ -978,6 +980,7 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu TurnState::Complete, None, None, + None, ), StateAction::ChatTurnCancelled(a) => end_turn( state, @@ -986,6 +989,7 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu TurnState::Cancelled, None, None, + None, ), StateAction::ChatError(a) => end_turn( state, @@ -994,6 +998,7 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu TurnState::Error, Some(SessionStatus::Error), Some(a.error.clone()), + a.resumable, ), StateAction::ChatActivityChanged(a) => { state.activity = a.activity.clone(); @@ -1256,10 +1261,7 @@ fn apply_turn_resumed(state: &mut ChatState, a: &ChatTurnResumedAction) -> Reduc let Some(turn) = state.turns.last() else { return ReduceOutcome::NoOp; }; - if turn.id != a.turn_id - || turn.state != TurnState::Error - || turn.error.as_ref().and_then(|error| error.resumable) != Some(true) - { + if turn.id != a.turn_id || turn.state != TurnState::Error || turn.resumable != Some(true) { return ReduceOutcome::NoOp; } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 0dc146f5..411fed14 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -910,6 +910,8 @@ public struct ChatErrorAction: Codable, Sendable { public var duration: Int /// Error details public var error: ErrorInfo + /// Whether the failed turn can be resumed without adding another message. + public var resumable: Bool? /// Additional provider-specific metadata for this action. /// /// Clients MAY look for well-known keys here to provide enhanced UI, and @@ -924,6 +926,7 @@ public struct ChatErrorAction: Codable, Sendable { case turnId case duration case error + case resumable case meta = "_meta" } @@ -932,12 +935,14 @@ public struct ChatErrorAction: Codable, Sendable { turnId: String, duration: Int, error: ErrorInfo, + resumable: Bool? = nil, meta: [String: AnyCodable]? = nil ) { self.type = type self.turnId = turnId self.duration = duration self.error = error + self.resumable = resumable self.meta = meta } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 7e7bc82e..78f83498 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -635,17 +635,13 @@ public struct AgentCapabilities: Codable, Sendable { /// set and MUST NOT set more than one entry in /// {@link CreateSessionParams.workingDirectories}. public var multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? - /// The agent can resume a failed turn without adding another message. - public var resumeTurn: AnyCodable? public init( multipleChats: MultipleChatsCapability? = nil, - multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = nil, - resumeTurn: AnyCodable? = nil + multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = nil ) { self.multipleChats = multipleChats self.multipleWorkingDirectories = multipleWorkingDirectories - self.resumeTurn = resumeTurn } } @@ -1551,6 +1547,8 @@ public struct Turn: Codable, Sendable { public var state: TurnState /// Error details if state is `'error'` public var error: ErrorInfo? + /// Whether this failed turn can be resumed without adding another message. + public var resumable: Bool? public init( id: String, @@ -1560,7 +1558,8 @@ public struct Turn: Codable, Sendable { responseParts: [ResponsePart], usage: UsageInfo? = nil, state: TurnState, - error: ErrorInfo? = nil + error: ErrorInfo? = nil, + resumable: Bool? = nil ) { self.id = id self.startedAt = startedAt @@ -1570,6 +1569,7 @@ public struct Turn: Codable, Sendable { self.usage = usage self.state = state self.error = error + self.resumable = resumable } } @@ -4778,8 +4778,6 @@ public struct ErrorInfo: Codable, Sendable { public var message: String /// Stack trace public var stack: String? - /// Whether the failed operation can be resumed without adding new user input. - public var resumable: Bool? /// Additional provider-specific metadata for this error. /// Clients MAY look for well-known optional keys here to provide enhanced UI /// (e.g. a structured chat fetch error for richer, localized messaging). @@ -4789,7 +4787,6 @@ public struct ErrorInfo: Codable, Sendable { case errorType case message case stack - case resumable case meta = "_meta" } @@ -4797,13 +4794,11 @@ public struct ErrorInfo: Codable, Sendable { errorType: String, message: String, stack: String? = nil, - resumable: Bool? = nil, meta: [String: AnyCodable]? = nil ) { self.errorType = errorType self.message = message self.stack = stack - self.resumable = resumable self.meta = meta } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 8d9e70eb..3615ecc1 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -158,7 +158,7 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { let turn = state.turns.last, turn.id == a.turnId, turn.state == .error, - turn.error?.resumable == true else { + turn.resumable == true else { return state } var next = state @@ -197,7 +197,7 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { return endTurn(state: state, turnId: a.turnId, duration: a.duration, turnState: .cancelled) case .chatError(let a): - return endTurn(state: state, turnId: a.turnId, duration: a.duration, turnState: .error, terminalStatus: .error, error: a.error) + return endTurn(state: state, turnId: a.turnId, duration: a.duration, turnState: .error, terminalStatus: .error, error: a.error, resumable: a.resumable) case .chatActivityChanged(let a): var next = state @@ -1070,7 +1070,8 @@ private func endTurn( duration: Int, turnState: TurnState, terminalStatus: SessionStatus? = nil, - error: ErrorInfo? = nil + error: ErrorInfo? = nil, + resumable: Bool? = nil ) -> ChatState { guard let activeTurn = state.activeTurn, activeTurn.id == turnId else { return state @@ -1127,7 +1128,8 @@ private func endTurn( responseParts: responseParts, usage: activeTurn.usage, state: turnState, - error: error + error: error, + resumable: resumable ) var next = state diff --git a/docs/.changes/20260717-resume-failed-turn.json b/docs/.changes/20260717-resume-failed-turn.json index 94ef9cdf..7e516b3e 100644 --- a/docs/.changes/20260717-resume-failed-turn.json +++ b/docs/.changes/20260717-resume-failed-turn.json @@ -1,4 +1,4 @@ { "type": "added", - "message": "`chat/turnResumed`, the `resumeTurn` agent capability, and resumable turn errors let clients continue failed turns without adding another user message." + "message": "`chat/turnResumed` and per-turn `resumable` eligibility let clients continue failed turns without adding another user message." } diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 8b2af5cb..48af33f1 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -225,7 +225,7 @@ When the server receives a client-dispatched action on this channel, it MUST val | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | Any action referencing a non-existent chat | Channel URI not found | Server MUST silently ignore the action (no echo) | | `chat/toolCallConfirmed` | Tool call not in `pending-confirmation` state | Server MUST reject the action | -| `chat/turnResumed` | The target is not the most recent turn, it is not failed with `error.resumable: true`, or another turn is active | Server MUST reject the action | +| `chat/turnResumed` | The target is not the most recent turn, it is not failed with `resumable: true`, or another turn is active | Server MUST reject the action | | `chat/turnCancelled` | No active turn | Server MUST reject the action | | `chat/inputAnswerChanged` | No input request with matching `requestId` | Server SHOULD reject the action | | `chat/inputAnswerChanged` | `answer.state` requires a value but `answer.value` is absent, or `answer.value.kind` is missing the matching payload field | Server SHOULD reject the action | diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 823d658c..76b26d9c 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -1279,6 +1279,10 @@ "$ref": "#/$defs/ErrorInfo", "description": "Error details" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed turn can be resumed without adding another message." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -2581,10 +2585,6 @@ "type": "string", "description": "Stack trace" }, - "resumable": { - "type": "boolean", - "description": "Whether the failed operation can be resumed without adding new user input." - }, "_meta": { "type": "object", "additionalProperties": {}, @@ -2736,11 +2736,6 @@ "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary}\n(some backends pin their first directory as a fixed process root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." - }, - "resumeTurn": { - "type": "object", - "properties": {}, - "description": "The agent can resume a failed turn without adding another message." } } }, @@ -5146,6 +5141,10 @@ "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details if state is `'error'`" + }, + "resumable": { + "type": "boolean", + "description": "Whether this failed turn can be resumed without adding another message." } }, "required": [ diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 6a45ebee..e6c2f403 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -1870,10 +1870,6 @@ "type": "string", "description": "Stack trace" }, - "resumable": { - "type": "boolean", - "description": "Whether the failed operation can be resumed without adding new user input." - }, "_meta": { "type": "object", "additionalProperties": {}, @@ -2025,11 +2021,6 @@ "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary}\n(some backends pin their first directory as a fixed process root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." - }, - "resumeTurn": { - "type": "object", - "properties": {}, - "description": "The agent can resume a failed turn without adding another message." } } }, @@ -4435,6 +4426,10 @@ "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details if state is `'error'`" + }, + "resumable": { + "type": "boolean", + "description": "Whether this failed turn can be resumed without adding another message." } }, "required": [ @@ -7666,6 +7661,10 @@ "$ref": "#/$defs/ErrorInfo", "description": "Error details" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed turn can be resumed without adding another message." + }, "_meta": { "type": "object", "additionalProperties": {}, diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 0202010d..0d3d1cec 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -466,10 +466,6 @@ "type": "string", "description": "Stack trace" }, - "resumable": { - "type": "boolean", - "description": "Whether the failed operation can be resumed without adding new user input." - }, "_meta": { "type": "object", "additionalProperties": {}, @@ -621,11 +617,6 @@ "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary}\n(some backends pin their first directory as a fixed process root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." - }, - "resumeTurn": { - "type": "object", - "properties": {}, - "description": "The agent can resume a failed turn without adding another message." } } }, @@ -3031,6 +3022,10 @@ "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details if state is `'error'`" + }, + "resumable": { + "type": "boolean", + "description": "Whether this failed turn can be resumed without adding another message." } }, "required": [ @@ -8585,6 +8580,10 @@ "$ref": "#/$defs/ErrorInfo", "description": "Error details" }, + "resumable": { + "type": "boolean", + "description": "Whether the failed turn can be resumed without adding another message." + }, "_meta": { "type": "object", "additionalProperties": {}, diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 22fef742..4c2f35b1 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -629,10 +629,6 @@ "type": "string", "description": "Stack trace" }, - "resumable": { - "type": "boolean", - "description": "Whether the failed operation can be resumed without adding new user input." - }, "_meta": { "type": "object", "additionalProperties": {}, @@ -784,11 +780,6 @@ "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary}\n(some backends pin their first directory as a fixed process root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." - }, - "resumeTurn": { - "type": "object", - "properties": {}, - "description": "The agent can resume a failed turn without adding another message." } } }, @@ -3194,6 +3185,10 @@ "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details if state is `'error'`" + }, + "resumable": { + "type": "boolean", + "description": "Whether this failed turn can be resumed without adding another message." } }, "required": [ diff --git a/schema/state.schema.json b/schema/state.schema.json index e43f001d..49c3b40f 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -377,10 +377,6 @@ "type": "string", "description": "Stack trace" }, - "resumable": { - "type": "boolean", - "description": "Whether the failed operation can be resumed without adding new user input." - }, "_meta": { "type": "object", "additionalProperties": {}, @@ -532,11 +528,6 @@ "multipleWorkingDirectories": { "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary}\n(some backends pin their first directory as a fixed process root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." - }, - "resumeTurn": { - "type": "object", - "properties": {}, - "description": "The agent can resume a failed turn without adding another message." } } }, @@ -2942,6 +2933,10 @@ "error": { "$ref": "#/$defs/ErrorInfo", "description": "Error details if state is `'error'`" + }, + "resumable": { + "type": "boolean", + "description": "Whether this failed turn can be resumed without adding another message." } }, "required": [ diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index 9a137228..d3ae43f5 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -503,6 +503,8 @@ export interface ChatErrorAction { duration: number; /** Error details */ error: ErrorInfo; + /** Whether the failed turn can be resumed without adding another message. */ + resumable?: boolean; /** * Additional provider-specific metadata for this action. * diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index 6ba45fd2..7a11d7f4 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -171,6 +171,7 @@ function endTurn( duration: number, terminalStatus?: SessionStatus.Error, error?: { errorType: string; message: string; stack?: string }, + resumable?: boolean, ): ChatState { if (!state.activeTurn || state.activeTurn.id !== turnId) { return state; @@ -209,6 +210,7 @@ function endTurn( usage: active.usage, state: turnState, error, + ...(resumable === true ? { resumable: true } : {}), }; const next: ChatState = { @@ -382,7 +384,7 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st if (!turn || turn.id !== action.turnId) { return state; } - if (turn.state !== TurnState.Error || turn.error?.resumable !== true) { + if (turn.state !== TurnState.Error || turn.resumable !== true) { return state; } const turns = state.turns.slice(); @@ -432,7 +434,7 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st return endTurn(state, action.turnId, TurnState.Cancelled, action.duration); case ActionType.ChatError: - return endTurn(state, action.turnId, TurnState.Error, action.duration, SessionStatus.Error, action.error); + return endTurn(state, action.turnId, TurnState.Error, action.duration, SessionStatus.Error, action.error, action.resumable); case ActionType.ChatActivityChanged: return { ...state, activity: action.activity }; diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index 8e107a23..a764453b 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -567,6 +567,8 @@ export interface Turn { state: TurnState; /** Error details if state is `'error'` */ error?: ErrorInfo; + /** Whether this failed turn can be resumed without adding another message. */ + resumable?: boolean; } /** diff --git a/types/channels-root/state.ts b/types/channels-root/state.ts index 30a6bc00..eaceed68 100644 --- a/types/channels-root/state.ts +++ b/types/channels-root/state.ts @@ -122,8 +122,6 @@ export interface AgentCapabilities { * {@link CreateSessionParams.workingDirectories}. */ multipleWorkingDirectories?: MultipleWorkingDirectoriesCapability; - /** The agent can resume a failed turn without adding another message. */ - resumeTurn?: {}; } /** diff --git a/types/common/state.ts b/types/common/state.ts index 0660f4ec..be6c2e16 100644 --- a/types/common/state.ts +++ b/types/common/state.ts @@ -315,8 +315,6 @@ export interface ErrorInfo { message: string; /** Stack trace */ stack?: string; - /** Whether the failed operation can be resumed without adding new user input. */ - resumable?: boolean; /** * Additional provider-specific metadata for this error. * Clients MAY look for well-known optional keys here to provide enhanced UI diff --git a/types/test-cases/reducers/256-chat-turnresumed-reopens-resumable-error.json b/types/test-cases/reducers/256-chat-turnresumed-reopens-resumable-error.json index 78fa9da7..aec0ca6c 100644 --- a/types/test-cases/reducers/256-chat-turnresumed-reopens-resumable-error.json +++ b/types/test-cases/reducers/256-chat-turnresumed-reopens-resumable-error.json @@ -34,9 +34,9 @@ "state": "error", "error": { "errorType": "runtime", - "message": "Request failed", - "resumable": true - } + "message": "Request failed" + }, + "resumable": true } ] }, diff --git a/types/test-cases/reducers/257-chat-turnresumed-completes-one-turn.json b/types/test-cases/reducers/257-chat-turnresumed-completes-one-turn.json index 708205f8..3e2bc4f2 100644 --- a/types/test-cases/reducers/257-chat-turnresumed-completes-one-turn.json +++ b/types/test-cases/reducers/257-chat-turnresumed-completes-one-turn.json @@ -31,9 +31,9 @@ "state": "error", "error": { "errorType": "runtime", - "message": "Request failed", - "resumable": true - } + "message": "Request failed" + }, + "resumable": true } ] }, diff --git a/types/test-cases/reducers/262-chat-turnresumed-can-resume-again-after-error.json b/types/test-cases/reducers/262-chat-turnresumed-can-resume-again-after-error.json index 7840e5de..2a1ac0c4 100644 --- a/types/test-cases/reducers/262-chat-turnresumed-can-resume-again-after-error.json +++ b/types/test-cases/reducers/262-chat-turnresumed-can-resume-again-after-error.json @@ -23,9 +23,9 @@ "state": "error", "error": { "errorType": "runtime", - "message": "First failure", - "resumable": true - } + "message": "First failure" + }, + "resumable": true } ] }, @@ -40,9 +40,9 @@ "duration": 1000, "error": { "errorType": "runtime", - "message": "Second failure", - "resumable": true - } + "message": "Second failure" + }, + "resumable": true }, { "type": "chat/turnResumed", diff --git a/types/test-cases/reducers/263-chat-turnresumed-noop-for-nonlatest-error.json b/types/test-cases/reducers/263-chat-turnresumed-noop-for-nonlatest-error.json index 1bc4673a..a88ec2b8 100644 --- a/types/test-cases/reducers/263-chat-turnresumed-noop-for-nonlatest-error.json +++ b/types/test-cases/reducers/263-chat-turnresumed-noop-for-nonlatest-error.json @@ -22,9 +22,9 @@ "state": "error", "error": { "errorType": "runtime", - "message": "Request failed", - "resumable": true - } + "message": "Request failed" + }, + "resumable": true }, { "id": "turn-2", @@ -66,9 +66,9 @@ "state": "error", "error": { "errorType": "runtime", - "message": "Request failed", - "resumable": true - } + "message": "Request failed" + }, + "resumable": true }, { "id": "turn-2", diff --git a/types/version/registry.ts b/types/version/registry.ts index eb58fb55..4fe29b89 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -109,7 +109,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.SessionConfigChanged]: '0.1.0', [ActionType.SessionMetaChanged]: '0.1.0', [ActionType.ChatTurnStarted]: '0.4.0', - [ActionType.ChatTurnResumed]: '0.7.0', + [ActionType.ChatTurnResumed]: '0.8.0', [ActionType.ChatDelta]: '0.4.0', [ActionType.ChatResponsePart]: '0.4.0', [ActionType.ChatToolCallStart]: '0.4.0',