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
40 changes: 36 additions & 4 deletions clients/go/ahp/reducers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -505,6 +506,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 All @@ -518,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
Expand Down Expand Up @@ -1034,6 +1037,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.Resumable == nil || !*turn.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
17 changes: 17 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 @@ -591,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
Expand Down Expand Up @@ -1501,6 +1511,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 +1662,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
2 changes: 2 additions & 0 deletions clients/go/ahptypes/state.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -1287,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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -434,6 +435,7 @@ private fun endTurn(
usage = active.usage,
state = turnState,
error = error,
resumable = resumable,
)

val withoutTurn = state.copy(
Expand Down Expand Up @@ -849,6 +851,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.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 Expand Up @@ -879,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)
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 @@ -754,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.
*
Expand Down Expand Up @@ -1546,6 +1561,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 +1662,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 +1756,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 @@ -1734,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
Expand Down
15 changes: 15 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 @@ -763,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<bool>,
/// Additional provider-specific metadata for this action.
///
/// Clients MAY look for well-known keys here to provide enhanced UI, and
Expand Down Expand Up @@ -1799,6 +1812,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
3 changes: 3 additions & 0 deletions clients/rust/crates/ahp-types/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1619,6 +1619,9 @@ pub struct Turn {
/// Error details if state is `'error'`
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorInfo>,
/// Whether this failed turn can be resumed without adding another message.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resumable: Option<bool>,
}

/// An in-progress turn — the assistant is actively streaming.
Expand Down
Loading
Loading