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
46 changes: 42 additions & 4 deletions clients/go/ahp/reducers.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@ func hasOpenInputRequest(state *ahptypes.ChatState) bool {
return false
}

func hasResumableError(turn *ahptypes.Turn) bool {
if len(turn.ResponseParts) == 0 {
return false
}
part, ok := turn.ResponseParts[len(turn.ResponseParts)-1].Value.(*ahptypes.ErrorResponsePart)
return ok && part.Resumable != nil && *part.Resumable
}

func summaryStatus(state *ahptypes.ChatState, terminal *ahptypes.SessionStatus) ahptypes.SessionStatus {
var activity ahptypes.SessionStatus
switch {
Expand All @@ -215,7 +223,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, errorPart *ahptypes.ErrorResponsePart) ReduceOutcome {
if state.ActiveTurn == nil || state.ActiveTurn.Id != turnID {
return ReduceOutcomeNoOp
}
Expand Down Expand Up @@ -253,6 +261,9 @@ func endTurn(state *ahptypes.ChatState, turnID string, duration int64, turnState
ToolCall: ahptypes.ToolCallState{Value: cancelled},
}})
}
if errorPart != nil {
parts = append(parts, ahptypes.ResponsePart{Value: errorPart})
}

// Defensive clamp: duration is producer-supplied and opaque to this
// reducer, but a negative value would be nonsensical to display.
Expand All @@ -268,7 +279,6 @@ func endTurn(state *ahptypes.ChatState, turnID string, duration int64, turnState
ResponseParts: parts,
Usage: active.Usage,
State: turnState,
Error: errInfo,
}

state.Turns = append(state.Turns, turn)
Expand Down Expand Up @@ -442,6 +452,7 @@ func updateResponsePart(state *ahptypes.ChatState, turnID, partID string, update
if state.ActiveTurn == nil || state.ActiveTurn.Id != turnID {
return ReduceOutcomeNoOp
}

for i := range state.ActiveTurn.ResponseParts {
part := &state.ActiveTurn.ResponseParts[i]
var id string
Expand Down Expand Up @@ -515,16 +526,43 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R
if state.ActiveTurn == nil || state.ActiveTurn.Id != a.TurnId {
return ReduceOutcomeNoOp
}
if _, ok := a.Part.Value.(*ahptypes.ErrorResponsePart); ok {
return ReduceOutcomeNoOp
}
state.ActiveTurn.ResponseParts = append(state.ActiveTurn.ResponseParts, a.Part)
return ReduceOutcomeApplied
case *ahptypes.ChatTurnCompleteAction:
return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateComplete, nil, nil)
case *ahptypes.ChatTurnCancelledAction:
return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateCancelled, nil, nil)
case *ahptypes.ChatErrorAction:
errCopy := a.Error
errStatus := ahptypes.SessionStatusError
return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateError, &errStatus, &errCopy)
return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateError, &errStatus, &a.Part)
case *ahptypes.ChatTurnResumeAction:
if state.ActiveTurn != nil || len(state.Turns) == 0 {
return ReduceOutcomeNoOp
}
turnIndex := len(state.Turns) - 1
turn := state.Turns[turnIndex]
if turn.Id != a.TurnId || turn.State != ahptypes.TurnStateError || !hasResumableError(&turn) {
return ReduceOutcomeNoOp
}

startedAt := state.ModifiedAt
if turn.StartedAt != nil {
startedAt = *turn.StartedAt
}
state.Turns = state.Turns[:turnIndex]
state.ActiveTurn = &ahptypes.ActiveTurn{
Id: turn.Id,
StartedAt: startedAt,
Message: turn.Message,
ResponseParts: turn.ResponseParts,
Usage: turn.Usage,
}
state.Status = withStatusFlag(summaryStatus(state, nil), ahptypes.SessionStatusIsRead, false)
touchChatModified(state)
return ReduceOutcomeApplied
case *ahptypes.ChatActivityChangedAction:
state.Activity = a.Activity
return ReduceOutcomeApplied
Expand Down
30 changes: 27 additions & 3 deletions clients/go/ahptypes/actions.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const (
ActionTypeChatTurnComplete ActionType = "chat/turnComplete"
ActionTypeChatTurnCancelled ActionType = "chat/turnCancelled"
ActionTypeChatError ActionType = "chat/error"
ActionTypeChatTurnResume ActionType = "chat/turnResume"
ActionTypeChatActivityChanged ActionType = "chat/activityChanged"
ActionTypeChatWorkingDirectorySet ActionType = "chat/workingDirectorySet"
ActionTypeChatWorkingDirectoryRemoved ActionType = "chat/workingDirectoryRemoved"
Expand Down Expand Up @@ -257,11 +258,14 @@ type ChatDeltaAction struct {
}

// Structured content appended to the response.
//
// An {@link ErrorResponsePart} MUST be appended with {@link ChatErrorAction}
// instead so adding the part and ending the turn are one atomic transition.
type ChatResponsePartAction struct {
Type ActionType `json:"type"`
// Turn identifier
TurnId string `json:"turnId"`
// Response part (markdown or content ref)
// Response part to append; error parts are ignored.
Part ResponsePart `json:"part"`
// Additional provider-specific metadata for this action.
//
Expand Down Expand Up @@ -589,8 +593,9 @@ type ChatErrorAction struct {
// client clocks may differ — and MUST treat it as opaque, producer-supplied
// data.
Duration int64 `json:"duration"`
// Error details
Error ErrorInfo `json:"error"`
// Error part to append to the response stream before finalizing the turn.
// Its optional `resumable` flag indicates whether the turn can be resumed.
Part ErrorResponsePart `json:"part"`
// Additional provider-specific metadata for this action.
//
// Clients MAY look for well-known keys here to provide enhanced UI, and
Expand All @@ -601,6 +606,18 @@ type ChatErrorAction struct {
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
}

// Resumes the latest errored turn without adding another message.
//
// The turn MUST be the latest turn, its state MUST be `error`, and its final
// response part MUST be a resumable error. The reducer reopens the same turn
// with its existing message, response parts, and usage intact. The host then
// resumes the provider's execution for that turn.
type ChatTurnResumeAction struct {
Type ActionType `json:"type"`
// Identifier of the errored turn.
TurnId string `json:"turnId"`
}

// The activity description of this chat changed.
//
// Dispatched by the server to indicate what the chat is currently doing
Expand Down Expand Up @@ -1515,6 +1532,7 @@ func (*ChatToolCallAuthResolvedAction) isStateAction() {}
func (*ChatTurnCompleteAction) isStateAction() {}
func (*ChatTurnCancelledAction) isStateAction() {}
func (*ChatErrorAction) isStateAction() {}
func (*ChatTurnResumeAction) isStateAction() {}
func (*ChatActivityChangedAction) isStateAction() {}
func (*SessionTitleChangedAction) isStateAction() {}
func (*ChatUsageAction) isStateAction() {}
Expand Down Expand Up @@ -1735,6 +1753,12 @@ func (u *StateAction) UnmarshalJSON(data []byte) error {
return err
}
u.Value = &value
case "chat/turnResume":
var value ChatTurnResumeAction
if err := json.Unmarshal(data, &value); err != nil {
return err
}
u.Value = &value
case "chat/activityChanged":
var value ChatActivityChangedAction
if err := json.Unmarshal(data, &value); err != nil {
Expand Down
28 changes: 26 additions & 2 deletions clients/go/ahptypes/state.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ const (
ResponsePartKindReasoning ResponsePartKind = "reasoning"
ResponsePartKindSystemNotification ResponsePartKind = "systemNotification"
ResponsePartKindInputRequest ResponsePartKind = "inputRequest"
ResponsePartKindError ResponsePartKind = "error"
)

// Status of a tool call in the lifecycle state machine.
Expand Down Expand Up @@ -1285,8 +1286,6 @@ type Turn struct {
Usage *UsageInfo `json:"usage,omitempty"`
// How the turn ended
State TurnState `json:"state"`
// Error details if state is `'error'`
Error *ErrorInfo `json:"error,omitempty"`
}

// An in-progress turn — the assistant is actively streaming.
Expand Down Expand Up @@ -1858,6 +1857,24 @@ type InputRequestResponsePart struct {
Response *ChatInputResponseKind `json:"response,omitempty"`
}

// An error encountered while processing a turn.
//
// This is the detailed source of truth for the error. {@link Turn.state}
// remains {@link TurnState.Error} while the turn is stopped at this error so
// clients can detect the terminal state without inspecting response parts.
//
// When {@link resumable} is `true`, a client may dispatch `chat/turnResume`
// while this is the latest turn and its state is {@link TurnState.Error}.
// Clients decide whether and how to present that affordance.
type ErrorResponsePart struct {
// Discriminant
Kind ResponsePartKind `json:"kind"`
// Error details.
Error ErrorInfo `json:"error"`
// Whether the host can resume the turn from this error. Only `true` enables resume.
Resumable *bool `json:"resumable,omitempty"`
}

// Tool execution result details, available after execution completes.
type ToolCallResult struct {
// Whether the tool succeeded
Expand Down Expand Up @@ -3605,6 +3622,7 @@ func (*ToolCallResponsePart) isResponsePart() {}
func (*ReasoningResponsePart) isResponsePart() {}
func (*SystemNotificationResponsePart) isResponsePart() {}
func (*InputRequestResponsePart) isResponsePart() {}
func (*ErrorResponsePart) isResponsePart() {}

// ResponsePartUnknown carries an unrecognized ResponsePart variant — typically a discriminator value introduced by a newer protocol version. The original JSON object is preserved verbatim so that re-encoding round-trips faithfully.
type ResponsePartUnknown struct {
Expand Down Expand Up @@ -3656,6 +3674,12 @@ func (u *ResponsePart) UnmarshalJSON(data []byte) error {
return err
}
u.Value = &value
case "error":
var value ErrorResponsePart
if err := json.Unmarshal(data, &value); err != nil {
return err
}
u.Value = &value
default:
raw := make(json.RawMessage, len(data))
copy(raw, data)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ private fun endTurn(
duration: Long,
turnState: TurnState,
terminalStatus: SessionStatus? = null,
error: ErrorInfo? = null,
errorPart: ErrorResponsePart? = null,
): ChatState {
val active = state.activeTurn ?: return state
if (active.id != turnId) return state
Expand Down Expand Up @@ -422,6 +422,11 @@ private fun endTurn(
),
)
}
val responseParts = if (errorPart == null) {
finalizedParts
} else {
finalizedParts + ResponsePartError(errorPart)
}

// Defensive clamp: `duration` is producer-supplied and opaque to this
// reducer, but a negative value would be nonsensical to display.
Expand All @@ -430,10 +435,9 @@ private fun endTurn(
startedAt = active.startedAt,
duration = maxOf(0L, duration),
message = active.message,
responseParts = finalizedParts,
responseParts = responseParts,
usage = active.usage,
state = turnState,
error = error,
)

val withoutTurn = state.copy(
Expand Down Expand Up @@ -863,7 +867,7 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when
is StateActionChatResponsePart -> {
val a = action.value
val activeTurn = state.activeTurn
if (activeTurn == null || activeTurn.id != a.turnId) {
if (activeTurn == null || activeTurn.id != a.turnId || a.part is ResponsePartError) {
state
} else {
state.copy(
Expand All @@ -879,7 +883,40 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when
endTurn(state, action.value.turnId, action.value.duration, TurnState.CANCELLED)

is StateActionChatError ->
endTurn(state, action.value.turnId, action.value.duration, TurnState.ERROR, SessionStatus.ERROR, action.value.error)
endTurn(state, action.value.turnId, action.value.duration, TurnState.ERROR, SessionStatus.ERROR, action.value.part)

is StateActionChatTurnResume -> {
val a = action.value
if (state.activeTurn != null || state.turns.isEmpty()) {
state
} else {
val turnIndex = state.turns.lastIndex
val turn = state.turns[turnIndex]
if (turn.id != a.turnId || turn.state != TurnState.ERROR) {
state
} else {
val errorPart = turn.responseParts.lastOrNull() as? ResponsePartError
if (errorPart?.value?.resumable != true) {
state
} else {
val withTurn = state.copy(
turns = state.turns.dropLast(1),
activeTurn = ActiveTurn(
id = turn.id,
startedAt = turn.startedAt ?: state.modifiedAt,
message = turn.message,
responseParts = turn.responseParts,
usage = turn.usage,
),
)
withTurn.copy(
status = withStatusFlag(chatSummaryStatus(withTurn), SessionStatus.IS_READ, false),
modifiedAt = nowIsoString(),
)
}
}
}
}

is StateActionChatActivityChanged ->
state.copy(activity = action.value.activity)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ enum class ActionType {
CHAT_TURN_CANCELLED,
@SerialName("chat/error")
CHAT_ERROR,
@SerialName("chat/turnResume")
CHAT_TURN_RESUME,
@SerialName("chat/activityChanged")
CHAT_ACTIVITY_CHANGED,
@SerialName("chat/workingDirectorySet")
Expand Down Expand Up @@ -363,7 +365,7 @@ data class ChatResponsePartAction(
*/
val turnId: String,
/**
* Response part (markdown or content ref)
* Response part to append; error parts are ignored.
*/
val part: ResponsePart,
/**
Expand Down Expand Up @@ -751,9 +753,10 @@ data class ChatErrorAction(
*/
val duration: Long,
/**
* Error details
* Error part to append to the response stream before finalizing the turn.
* Its optional `resumable` flag indicates whether the turn can be resumed.
*/
val error: ErrorInfo,
val part: ErrorResponsePart,
/**
* Additional provider-specific metadata for this action.
*
Expand All @@ -767,6 +770,15 @@ data class ChatErrorAction(
val meta: Map<String, JsonElement>? = null
)

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

@Serializable
data class ChatActivityChangedAction(
val type: ActionType,
Expand Down Expand Up @@ -1560,6 +1572,7 @@ sealed interface StateAction
@JvmInline value class StateActionChatTurnComplete(val value: ChatTurnCompleteAction) : StateAction
@JvmInline value class StateActionChatTurnCancelled(val value: ChatTurnCancelledAction) : StateAction
@JvmInline value class StateActionChatError(val value: ChatErrorAction) : StateAction
@JvmInline value class StateActionChatTurnResume(val value: ChatTurnResumeAction) : StateAction
@JvmInline value class StateActionChatActivityChanged(val value: ChatActivityChangedAction) : StateAction
@JvmInline value class StateActionSessionTitleChanged(val value: SessionTitleChangedAction) : StateAction
@JvmInline value class StateActionChatUsage(val value: ChatUsageAction) : StateAction
Expand Down Expand Up @@ -1660,6 +1673,7 @@ internal object StateActionSerializer : KSerializer<StateAction> {
"chat/turnComplete" -> StateActionChatTurnComplete(input.json.decodeFromJsonElement(ChatTurnCompleteAction.serializer(), element))
"chat/turnCancelled" -> StateActionChatTurnCancelled(input.json.decodeFromJsonElement(ChatTurnCancelledAction.serializer(), element))
"chat/error" -> StateActionChatError(input.json.decodeFromJsonElement(ChatErrorAction.serializer(), element))
"chat/turnResume" -> StateActionChatTurnResume(input.json.decodeFromJsonElement(ChatTurnResumeAction.serializer(), element))
"chat/activityChanged" -> StateActionChatActivityChanged(input.json.decodeFromJsonElement(ChatActivityChangedAction.serializer(), element))
"session/titleChanged" -> StateActionSessionTitleChanged(input.json.decodeFromJsonElement(SessionTitleChangedAction.serializer(), element))
"chat/usage" -> StateActionChatUsage(input.json.decodeFromJsonElement(ChatUsageAction.serializer(), element))
Expand Down Expand Up @@ -1753,6 +1767,7 @@ internal object StateActionSerializer : KSerializer<StateAction> {
is StateActionChatTurnComplete -> output.json.encodeToJsonElement(ChatTurnCompleteAction.serializer(), value.value)
is StateActionChatTurnCancelled -> output.json.encodeToJsonElement(ChatTurnCancelledAction.serializer(), value.value)
is StateActionChatError -> output.json.encodeToJsonElement(ChatErrorAction.serializer(), value.value)
is StateActionChatTurnResume -> output.json.encodeToJsonElement(ChatTurnResumeAction.serializer(), value.value)
is StateActionChatActivityChanged -> output.json.encodeToJsonElement(ChatActivityChangedAction.serializer(), value.value)
is StateActionSessionTitleChanged -> output.json.encodeToJsonElement(SessionTitleChangedAction.serializer(), value.value)
is StateActionChatUsage -> output.json.encodeToJsonElement(ChatUsageAction.serializer(), value.value)
Expand Down
Loading