Skip to content
Merged
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
28 changes: 21 additions & 7 deletions clients/go/ahp/reducers.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,30 @@ func withStatusFlag(status, flag ahptypes.SessionStatus, set bool) ahptypes.Sess
return status &^ flag
}

// awaitsUser reports whether an entry blocks on the *user*.
//
// SessionInputRequestKindToolClientExecution is work delegated to a client, not
// a prompt: the call has already cleared its confirmation gate and is simply
// running somewhere else. Counting it would report a session as awaiting the
// user for the entire duration of every client tool call.
func awaitsUser(request ahptypes.SessionInputRequest) bool {
_, isClientExecution := request.Value.(*ahptypes.SessionToolClientExecutionRequest)
return !isClientExecution
}

// withInputNeededStatus reflects the session-level input queue into the activity
// bits of status. A non-empty queue promotes the activity to InputNeeded;
// emptying it clears the input-needed-specific bit. Because InputNeeded implies
// InProgress, an unblocked turn falls back to InProgress while an already-idle
// session stays idle. Orthogonal flags (IsRead / IsArchived) are preserved.
// bits of status. A queue holding any user-blocking entry promotes the activity
// to InputNeeded; draining those entries clears the input-needed-specific bit.
// Because InputNeeded implies InProgress, an unblocked turn falls back to
// InProgress while an already-idle session stays idle. Orthogonal flags
// (IsRead / IsArchived) are preserved.
func withInputNeededStatus(status ahptypes.SessionStatus, inputNeeded []ahptypes.SessionInputRequest) ahptypes.SessionStatus {
if len(inputNeeded) == 0 {
return status &^ (ahptypes.SessionStatusInputNeeded &^ ahptypes.SessionStatusInProgress)
for _, request := range inputNeeded {
if awaitsUser(request) {
return (status &^ statusActivityMask) | ahptypes.SessionStatusInputNeeded
}
}
return (status &^ statusActivityMask) | ahptypes.SessionStatusInputNeeded
return status &^ (ahptypes.SessionStatusInputNeeded &^ ahptypes.SessionStatusInProgress)
}

// ─── Tool-call helpers ─────────────────────────────────────────────────
Expand Down
14 changes: 11 additions & 3 deletions clients/go/ahptypes/state.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -823,9 +823,12 @@ type SessionState struct {
// Each entry is self-sufficient: it carries the owning chat's URI plus every
// identifier the client needs to respond. A client answers by dispatching the
// ordinary `chat/*` action to that chat's channel — see
// {@link SessionInputRequest} for the per-variant response path. A present,
// non-empty list implies {@link SessionStatus.InputNeeded} on
// {@link SessionSummary.status}.
// {@link SessionInputRequest} for the per-variant response path. A list
// holding any entry other than
// {@link SessionInputRequestKind.ToolClientExecution} implies
// {@link SessionStatus.InputNeeded} on {@link SessionSummary.status};
// client-execution entries are work delegated to a client rather than a
// prompt, so they leave the session's activity unchanged.
//
// Host-managed: the host upserts entries with `session/inputNeededSet` as
// chats raise requests and removes them with `session/inputNeededRemoved`
Expand Down Expand Up @@ -920,6 +923,11 @@ type SessionToolConfirmationRequest struct {
// `chat/toolCallComplete` (and optionally streaming with
// `chat/toolCallContentChanged`) to {@link SessionInputRequestBase.chat |
// `chat`}, keyed by `turnId` and `toolCall.toolCallId`.
//
// Unlike the other variants this does **not** raise
// {@link SessionStatus.InputNeeded}: the call has already cleared its
// confirmation gate and is merely executing elsewhere, so the session stays
// {@link SessionStatus.InProgress} while it runs.
type SessionToolClientExecutionRequest struct {
// Stable key for this entry, unique within the session's
// {@link SessionState.inputNeeded} list. The host derives it however it likes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,28 @@ private fun withStatusFlag(status: SessionStatus, flag: SessionStatus, set: Bool
SessionStatus(status.rawValue and flag.rawValue.inv())
}

/**
* Whether an entry blocks on the *user*.
*
* A client-execution entry is work delegated to a client, not a prompt: the
* call has already cleared its confirmation gate and is simply running
* somewhere else. Counting it would report a session as awaiting the user for
* the entire duration of every client tool call.
*/
private fun awaitsUser(request: SessionInputRequest): Boolean =
request !is SessionInputRequestToolClientExecution

/**
* Reflects the session-level [SessionState.inputNeeded] queue into the activity
* bits of [status]. A non-empty queue promotes the activity to
* [SessionStatus.INPUT_NEEDED]; emptying it clears the input-needed-specific
* bit. Since INPUT_NEEDED implies [SessionStatus.IN_PROGRESS], an unblocked turn
* falls back to IN_PROGRESS while an already-idle session stays idle. Orthogonal
* flags (IS_READ / IS_ARCHIVED) are preserved.
* bits of [status]. A queue holding any user-blocking entry promotes the
* activity to [SessionStatus.INPUT_NEEDED]; draining those entries clears the
* input-needed-specific bit. Since INPUT_NEEDED implies
* [SessionStatus.IN_PROGRESS], an unblocked turn falls back to IN_PROGRESS
* while an already-idle session stays idle. Orthogonal flags (IS_READ /
* IS_ARCHIVED) are preserved.
*/
private fun withInputNeededStatus(status: SessionStatus, inputNeeded: List<SessionInputRequest>): SessionStatus =
if (inputNeeded.isNotEmpty()) {
if (inputNeeded.any(::awaitsUser)) {
SessionStatus((status.rawValue and STATUS_ACTIVITY_MASK.inv()) or SessionStatus.INPUT_NEEDED.rawValue)
} else {
val inputBit = SessionStatus.INPUT_NEEDED.rawValue and SessionStatus.IN_PROGRESS.rawValue.inv()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1429,9 +1429,12 @@ data class SessionState(
* Each entry is self-sufficient: it carries the owning chat's URI plus every
* identifier the client needs to respond. A client answers by dispatching the
* ordinary `chat/​*` action to that chat's channel — see
* {@link SessionInputRequest} for the per-variant response path. A present,
* non-empty list implies {@link SessionStatus.InputNeeded} on
* {@link SessionSummary.status}.
* {@link SessionInputRequest} for the per-variant response path. A list
* holding any entry other than
* {@link SessionInputRequestKind.ToolClientExecution} implies
* {@link SessionStatus.InputNeeded} on {@link SessionSummary.status};
* client-execution entries are work delegated to a client rather than a
* prompt, so they leave the session's activity unchanged.
*
* Host-managed: the host upserts entries with `session/inputNeededSet` as
* chats raise requests and removes them with `session/inputNeededRemoved`
Expand Down
14 changes: 11 additions & 3 deletions clients/rust/crates/ahp-types/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1234,9 +1234,12 @@ pub struct SessionState {
/// Each entry is self-sufficient: it carries the owning chat's URI plus every
/// identifier the client needs to respond. A client answers by dispatching the
/// ordinary `chat/*` action to that chat's channel — see
/// {@link SessionInputRequest} for the per-variant response path. A present,
/// non-empty list implies {@link SessionStatus.InputNeeded} on
/// {@link SessionSummary.status}.
/// {@link SessionInputRequest} for the per-variant response path. A list
/// holding any entry other than
/// {@link SessionInputRequestKind.ToolClientExecution} implies
/// {@link SessionStatus.InputNeeded} on {@link SessionSummary.status};
/// client-execution entries are work delegated to a client rather than a
/// prompt, so they leave the session's activity unchanged.
///
/// Host-managed: the host upserts entries with `session/inputNeededSet` as
/// chats raise requests and removes them with `session/inputNeededRemoved`
Expand Down Expand Up @@ -1339,6 +1342,11 @@ pub struct SessionToolConfirmationRequest {
/// `chat/toolCallComplete` (and optionally streaming with
/// `chat/toolCallContentChanged`) to {@link SessionInputRequestBase.chat |
/// `chat`}, keyed by `turnId` and `toolCall.toolCallId`.
///
/// Unlike the other variants this does **not** raise
/// {@link SessionStatus.InputNeeded}: the call has already cleared its
/// confirmation gate and is merely executing elsewhere, so the session stays
/// {@link SessionStatus.InProgress} while it runs.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionToolClientExecutionRequest {
Expand Down
25 changes: 18 additions & 7 deletions clients/rust/crates/ahp/src/reducers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,16 +277,27 @@ fn with_status_flag(status: u32, flag: SessionStatus, set: bool) -> u32 {
}
}

/// Whether an entry blocks on the *user*.
///
/// `ToolClientExecution` is work delegated to a client, not a prompt: the call
/// has already cleared its confirmation gate and is simply running somewhere
/// else. Counting it would report a session as awaiting the user for the
/// entire duration of every client tool call.
fn awaits_user(request: &SessionInputRequest) -> bool {
!matches!(request, SessionInputRequest::ToolClientExecution(_))
}

/// Reflects the session-level input queue into the activity bits of `status`.
/// A non-empty queue promotes the activity to `InputNeeded`; emptying it clears
/// the input-needed-specific bit. Since `InputNeeded` implies `InProgress`, an
/// unblocked turn falls back to `InProgress` while an already-idle session stays
/// idle. Orthogonal flags (`IsRead` / `IsArchived`) are preserved.
/// A queue holding any user-blocking entry promotes the activity to
/// `InputNeeded`; draining those entries clears the input-needed-specific bit.
/// Since `InputNeeded` implies `InProgress`, an unblocked turn falls back to
/// `InProgress` while an already-idle session stays idle. Orthogonal flags
/// (`IsRead` / `IsArchived`) are preserved.
fn with_input_needed_status(status: u32, input_needed: &[SessionInputRequest]) -> u32 {
if input_needed.is_empty() {
status & !(SessionStatus::InputNeeded.bits() & !SessionStatus::InProgress.bits())
} else {
if input_needed.iter().any(awaits_user) {
(status & !STATUS_ACTIVITY_MASK) | SessionStatus::InputNeeded.bits()
} else {
status & !(SessionStatus::InputNeeded.bits() & !SessionStatus::InProgress.bits())
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1152,9 +1152,12 @@ public struct SessionState: Codable, Sendable {
/// Each entry is self-sufficient: it carries the owning chat's URI plus every
/// identifier the client needs to respond. A client answers by dispatching the
/// ordinary `chat/*` action to that chat's channel — see
/// {@link SessionInputRequest} for the per-variant response path. A present,
/// non-empty list implies {@link SessionStatus.InputNeeded} on
/// {@link SessionSummary.status}.
/// {@link SessionInputRequest} for the per-variant response path. A list
/// holding any entry other than
/// {@link SessionInputRequestKind.ToolClientExecution} implies
/// {@link SessionStatus.InputNeeded} on {@link SessionSummary.status};
/// client-execution entries are work delegated to a client rather than a
/// prompt, so they leave the session's activity unchanged.
///
/// Host-managed: the host upserts entries with `session/inputNeededSet` as
/// chats raise requests and removes them with `session/inputNeededRemoved`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,30 @@ private func withStatusFlag(_ status: SessionStatus, _ flag: SessionStatus, _ se
set ? status.union(flag) : status.subtracting(flag)
}

/// Whether an entry blocks on the *user*.
///
/// `.toolClientExecution` is work delegated to a client, not a prompt: the call
/// has already cleared its confirmation gate and is simply running somewhere
/// else. Counting it would report a session as awaiting the user for the entire
/// duration of every client tool call.
private func awaitsUser(_ request: SessionInputRequest) -> Bool {
if case .toolClientExecution = request {
return false
}
return true
}

/// Reflects the session-level input queue into the activity bits of `status`.
/// A non-empty queue promotes the activity to `.inputNeeded`; emptying it clears
/// the input-needed-specific bit. Since `.inputNeeded` implies `.inProgress`, an
/// unblocked turn falls back to `.inProgress` while an already-idle session stays
/// idle. Orthogonal flags (`.isRead` / `.isArchived`) are preserved.
/// A queue holding any user-blocking entry promotes the activity to
/// `.inputNeeded`; draining those entries clears the input-needed-specific bit.
/// Since `.inputNeeded` implies `.inProgress`, an unblocked turn falls back to
/// `.inProgress` while an already-idle session stays idle. Orthogonal flags
/// (`.isRead` / `.isArchived`) are preserved.
private func withInputNeededStatus(_ status: SessionStatus, _ inputNeeded: [SessionInputRequest]) -> SessionStatus {
if inputNeeded.isEmpty {
return status.subtracting(SessionStatus.inputNeeded.subtracting(.inProgress))
if inputNeeded.contains(where: awaitsUser) {
return status.subtracting(statusActivityMask).union(.inputNeeded)
}
return status.subtracting(statusActivityMask).union(.inputNeeded)
return status.subtracting(SessionStatus.inputNeeded.subtracting(.inProgress))
}

/// Resolves a selected confirmation option by ID from a pending-confirmation state.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "changed",
"message": "`SessionState.inputNeeded` entries of kind `toolClientExecution` no longer raise `SessionStatus.InputNeeded`. Such an entry is work delegated to a client, not a user prompt, so a session stays `InProgress` while a client tool runs."
}
4 changes: 2 additions & 2 deletions schema/actions.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2962,7 +2962,7 @@
"items": {
"$ref": "#/$defs/SessionInputRequest"
},
"description": "Outstanding input the session is blocked on, aggregated across every chat\nso a client can discover and answer it from the session channel alone,\nwithout subscribing to individual chats.\n\nEach entry is self-sufficient: it carries the owning chat's URI plus every\nidentifier the client needs to respond. A client answers by dispatching the\nordinary `chat/*` action to that chat's channel — see\n{@link SessionInputRequest} for the per-variant response path. A present,\nnon-empty list implies {@link SessionStatus.InputNeeded} on\n{@link SessionSummary.status}.\n\nHost-managed: the host upserts entries with `session/inputNeededSet` as\nchats raise requests and removes them with `session/inputNeededRemoved`\nonce the underlying request resolves."
"description": "Outstanding input the session is blocked on, aggregated across every chat\nso a client can discover and answer it from the session channel alone,\nwithout subscribing to individual chats.\n\nEach entry is self-sufficient: it carries the owning chat's URI plus every\nidentifier the client needs to respond. A client answers by dispatching the\nordinary `chat/*` action to that chat's channel — see\n{@link SessionInputRequest} for the per-variant response path. A list\nholding any entry other than\n{@link SessionInputRequestKind.ToolClientExecution} implies\n{@link SessionStatus.InputNeeded} on {@link SessionSummary.status};\nclient-execution entries are work delegated to a client rather than a\nprompt, so they leave the session's activity unchanged.\n\nHost-managed: the host upserts entries with `session/inputNeededSet` as\nchats raise requests and removes them with `session/inputNeededRemoved`\nonce the underlying request resolves."
},
"_meta": {
"type": "object",
Expand Down Expand Up @@ -3090,7 +3090,7 @@
},
"SessionToolClientExecutionRequest": {
"type": "object",
"description": "A running tool whose execution is delegated to an active client. Surfaced so\na client that provides the tool can pick up the work without subscribing to\nthe owning chat.\n\nThe {@link toolCall} is always a {@link ToolCallRunningState} (a\n{@link ToolCallState} in `running` status) whose\n{@link ToolCallRunningState.contributor | `contributor`} is a client\n{@link ToolCallClientContributor} whose `clientId` matches the denormalized\n{@link clientId} here. Execute and report the result by dispatching\n`chat/toolCallComplete` (and optionally streaming with\n`chat/toolCallContentChanged`) to {@link SessionInputRequestBase.chat |\n`chat`}, keyed by `turnId` and `toolCall.toolCallId`.",
"description": "A running tool whose execution is delegated to an active client. Surfaced so\na client that provides the tool can pick up the work without subscribing to\nthe owning chat.\n\nThe {@link toolCall} is always a {@link ToolCallRunningState} (a\n{@link ToolCallState} in `running` status) whose\n{@link ToolCallRunningState.contributor | `contributor`} is a client\n{@link ToolCallClientContributor} whose `clientId` matches the denormalized\n{@link clientId} here. Execute and report the result by dispatching\n`chat/toolCallComplete` (and optionally streaming with\n`chat/toolCallContentChanged`) to {@link SessionInputRequestBase.chat |\n`chat`}, keyed by `turnId` and `toolCall.toolCallId`.\n\nUnlike the other variants this does **not** raise\n{@link SessionStatus.InputNeeded}: the call has already cleared its\nconfirmation gate and is merely executing elsewhere, so the session stays\n{@link SessionStatus.InProgress} while it runs.",
"properties": {
"id": {
"type": "string",
Expand Down
Loading
Loading