Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions clients/go/ahp/reducers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions clients/go/ahptypes/actions.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {}
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions clients/go/ahptypes/state.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -327,6 +329,15 @@ data class ChatTurnStartedAction(
val meta: Map<String, JsonElement>? = null
)

@Serializable
data class ChatTurnResumedAction(
val type: ActionType,
/**
* Identifier of the resumable failed turn.
*/
val turnId: String
)

@Serializable
data class ChatDeltaAction(
val type: ActionType,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1646,6 +1658,7 @@ internal object StateActionSerializer : KSerializer<StateAction> {
"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))
Expand Down Expand Up @@ -1739,6 +1752,7 @@ internal object StateActionSerializer : KSerializer<StateAction> {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions clients/rust/crates/ahp-types/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -352,6 +354,14 @@ pub struct ChatTurnStartedAction {
pub meta: Option<JsonObject>,
}

/// 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
Expand Down Expand Up @@ -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")]
Expand Down
6 changes: 6 additions & 0 deletions clients/rust/crates/ahp-types/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,9 @@ pub struct AgentCapabilities {
/// {@link CreateSessionParams.workingDirectories}.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multiple_working_directories: Option<MultipleWorkingDirectoriesCapability>,
/// The agent can resume a failed turn without adding another message.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume_turn: Option<AnyValue>,
}

/// Options for the {@link AgentCapabilities.multipleChats} capability.
Expand Down Expand Up @@ -3837,6 +3840,9 @@ pub struct ErrorInfo {
/// Stack trace
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stack: Option<String>,
/// Whether the failed operation can be resumed without adding new user input.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resumable: Option<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).
Expand Down
31 changes: 30 additions & 1 deletion clients/rust/crates/ahp/src/reducers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading