From 8d345c9ea168190aa8fcadfddf80af472527be73 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sat, 25 Jul 2026 16:11:53 -0700 Subject: [PATCH 1/3] 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/3] 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/3] 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). *