From 832a4806abf9ddcd5de88950e6e5d057136a955a Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 3 Aug 2026 14:34:12 -0700 Subject: [PATCH] session: client-execution entries no longer raise InputNeeded SessionStatus.InputNeeded is documented as "blocked waiting for user input or tool confirmation". A toolClientExecution entry is neither: the call has already cleared its confirmation gate and is simply running on a client. Counting it meant a session reported "input needed" for the whole duration of every client tool call, and that a call kept presenting as blocked after the user had already approved it. withInputNeededStatus now promotes only when the queue holds a user-blocking entry, ported across the Go, Rust, Kotlin and Swift reducers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahp/reducers.go | 28 ++++++-- clients/go/ahptypes/state.generated.go | 14 +++- .../microsoft/agenthostprotocol/Reducers.kt | 24 +++++-- .../generated/State.generated.kt | 9 ++- clients/rust/crates/ahp-types/src/state.rs | 14 +++- clients/rust/crates/ahp/src/reducers.rs | 25 +++++-- .../Generated/State.generated.swift | 9 ++- .../Sources/AgentHostProtocol/Reducers.swift | 28 ++++++-- ...nput-needed-excludes-client-execution.json | 4 ++ schema/actions.schema.json | 4 +- schema/commands.schema.json | 4 +- schema/errors.schema.json | 4 +- schema/notifications.schema.json | 4 +- schema/state.schema.json | 4 +- types/channels-session/reducer.ts | 21 ++++-- types/channels-session/state.ts | 14 +++- ...sion-inputneededset-replaces-existing.json | 4 +- ...set-client-execution-keeps-inprogress.json | 59 ++++++++++++++++ ...d-client-execution-remains-inprogress.json | 68 +++++++++++++++++++ 19 files changed, 283 insertions(+), 58 deletions(-) create mode 100644 docs/.changes/20260803-input-needed-excludes-client-execution.json create mode 100644 types/test-cases/reducers/261-session-inputneededset-client-execution-keeps-inprogress.json create mode 100644 types/test-cases/reducers/262-session-inputneededremoved-client-execution-remains-inprogress.json diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index f912928e..9d2a3416 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -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 ───────────────────────────────────────────────── diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 78c43dd2..99693a7b 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -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` @@ -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 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 d1579ce1..6222ed85 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -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): 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() 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 840af62b..d6fe2b4e 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 @@ -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` diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index b02d4257..0e9f5cba 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -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` @@ -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 { diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index a2cf5109..027f48e0 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -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()) } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 4c3adb9b..7ec7dd9b 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -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` diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 4e22c8e2..4dfa1c2c 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -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. diff --git a/docs/.changes/20260803-input-needed-excludes-client-execution.json b/docs/.changes/20260803-input-needed-excludes-client-execution.json new file mode 100644 index 00000000..a6012c91 --- /dev/null +++ b/docs/.changes/20260803-input-needed-excludes-client-execution.json @@ -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." +} diff --git a/schema/actions.schema.json b/schema/actions.schema.json index f5d965f1..fffbb460 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -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", @@ -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", diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 9ab17160..2010b737 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -2131,7 +2131,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", @@ -2259,7 +2259,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", diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 77945879..e206c637 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -867,7 +867,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", @@ -995,7 +995,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", diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index e95f38c8..ab95e4b4 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -1030,7 +1030,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", @@ -1158,7 +1158,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", diff --git a/schema/state.schema.json b/schema/state.schema.json index 07be2112..d594f6b4 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -778,7 +778,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", @@ -906,7 +906,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", diff --git a/types/channels-session/reducer.ts b/types/channels-session/reducer.ts index 3847c9f1..c62517b2 100644 --- a/types/channels-session/reducer.ts +++ b/types/channels-session/reducer.ts @@ -13,6 +13,7 @@ import type { import { SessionLifecycle, SessionStatus, + SessionInputRequestKind, CustomizationType, McpServerStatus, } from './state.js'; @@ -29,17 +30,29 @@ function withStatusFlag(status: SessionStatus, flag: SessionStatus, set: boolean return set ? status | flag : status & ~flag; } +/** + * Whether an entry blocks on the *user*. + * + * {@link SessionInputRequestKind.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. + */ +function awaitsUser(request: SessionInputRequest): boolean { + return request.kind !== SessionInputRequestKind.ToolClientExecution; +} + /** * Reflects the session-level {@link SessionState.inputNeeded | input queue} - * into the activity bits of `status`. A non-empty queue promotes the activity - * to {@link SessionStatus.InputNeeded}; emptying it clears the - * input-needed-specific bit. Since `InputNeeded` implies + * into the activity bits of `status`. A queue holding any user-blocking entry + * promotes the activity to {@link SessionStatus.InputNeeded}; draining those + * entries clears the input-needed-specific bit. Since `InputNeeded` implies * {@link SessionStatus.InProgress}, an unblocked turn falls back to * `InProgress` while an already-idle session stays idle. Orthogonal flags * (`IsRead` / `IsArchived`) are preserved. */ function withInputNeededStatus(status: SessionStatus, inputNeeded: readonly SessionInputRequest[]): SessionStatus { - if (inputNeeded.length > 0) { + if (inputNeeded.some(awaitsUser)) { return (status & ~STATUS_ACTIVITY_MASK) | SessionStatus.InputNeeded; } return status & ~(SessionStatus.InputNeeded & ~SessionStatus.InProgress); diff --git a/types/channels-session/state.ts b/types/channels-session/state.ts index f3d3f51e..e26fecfc 100644 --- a/types/channels-session/state.ts +++ b/types/channels-session/state.ts @@ -183,9 +183,12 @@ export interface SessionState extends SessionMetadata { * 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` @@ -323,6 +326,11 @@ export interface SessionToolConfirmationRequest extends SessionInputRequestBase * `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. + * * @category Session Input Types */ export interface SessionToolClientExecutionRequest extends SessionInputRequestBase { diff --git a/types/test-cases/reducers/225-session-inputneededset-replaces-existing.json b/types/test-cases/reducers/225-session-inputneededset-replaces-existing.json index 9210621f..ae46aa2e 100644 --- a/types/test-cases/reducers/225-session-inputneededset-replaces-existing.json +++ b/types/test-cases/reducers/225-session-inputneededset-replaces-existing.json @@ -1,5 +1,5 @@ { - "description": "session/inputNeededSet replaces an existing entry with the same id and keeps status InputNeeded", + "description": "session/inputNeededSet replaces an existing entry with the same id, and swapping a confirmation for a client execution drops status back to InProgress", "reducer": "session", "initial": { "provider": "copilot", @@ -48,7 +48,7 @@ "expected": { "provider": "copilot", "title": "Test Session", - "status": 24, + "status": 8, "lifecycle": "ready", "activeClients": [], "chats": [], diff --git a/types/test-cases/reducers/261-session-inputneededset-client-execution-keeps-inprogress.json b/types/test-cases/reducers/261-session-inputneededset-client-execution-keeps-inprogress.json new file mode 100644 index 00000000..7763e0c1 --- /dev/null +++ b/types/test-cases/reducers/261-session-inputneededset-client-execution-keeps-inprogress.json @@ -0,0 +1,59 @@ +{ + "description": "session/inputNeededSet with a tool-client-execution request leaves an in-progress session InProgress", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 8, + "lifecycle": "ready", + "activeClients": [], + "chats": [] + }, + "actions": [ + { + "type": "session/inputNeededSet", + "request": { + "kind": "toolClientExecution", + "id": "copilot:/test-session/chat-1#call-9", + "chat": "copilot:/test-session/chat-1", + "turnId": "turn-1", + "clientId": "vscode-1", + "toolCall": { + "status": "running", + "toolCallId": "call-9", + "toolName": "run_tool", + "displayName": "Run Tool", + "invocationMessage": "Run the client tool", + "confirmed": "not-needed", + "contributor": { "kind": "client", "clientId": "vscode-1" } + } + } + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 8, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "inputNeeded": [ + { + "kind": "toolClientExecution", + "id": "copilot:/test-session/chat-1#call-9", + "chat": "copilot:/test-session/chat-1", + "turnId": "turn-1", + "clientId": "vscode-1", + "toolCall": { + "status": "running", + "toolCallId": "call-9", + "toolName": "run_tool", + "displayName": "Run Tool", + "invocationMessage": "Run the client tool", + "confirmed": "not-needed", + "contributor": { "kind": "client", "clientId": "vscode-1" } + } + } + ] + } +} diff --git a/types/test-cases/reducers/262-session-inputneededremoved-client-execution-remains-inprogress.json b/types/test-cases/reducers/262-session-inputneededremoved-client-execution-remains-inprogress.json new file mode 100644 index 00000000..acef1d58 --- /dev/null +++ b/types/test-cases/reducers/262-session-inputneededremoved-client-execution-remains-inprogress.json @@ -0,0 +1,68 @@ +{ + "description": "session/inputNeededRemoved clears InputNeeded once only a tool-client-execution entry remains", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 24, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "inputNeeded": [ + { + "kind": "chatInput", + "id": "copilot:/test-session/chat-1#req-1", + "chat": "copilot:/test-session/chat-1", + "request": { "id": "req-1", "message": "Which environment?" } + }, + { + "kind": "toolClientExecution", + "id": "copilot:/test-session/chat-1#call-9", + "chat": "copilot:/test-session/chat-1", + "turnId": "turn-1", + "clientId": "vscode-1", + "toolCall": { + "status": "running", + "toolCallId": "call-9", + "toolName": "run_tool", + "displayName": "Run Tool", + "invocationMessage": "Run the client tool", + "confirmed": "not-needed", + "contributor": { "kind": "client", "clientId": "vscode-1" } + } + } + ] + }, + "actions": [ + { + "type": "session/inputNeededRemoved", + "id": "copilot:/test-session/chat-1#req-1" + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 8, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "inputNeeded": [ + { + "kind": "toolClientExecution", + "id": "copilot:/test-session/chat-1#call-9", + "chat": "copilot:/test-session/chat-1", + "turnId": "turn-1", + "clientId": "vscode-1", + "toolCall": { + "status": "running", + "toolCallId": "call-9", + "toolName": "run_tool", + "displayName": "Run Tool", + "invocationMessage": "Run the client tool", + "confirmed": "not-needed", + "contributor": { "kind": "client", "clientId": "vscode-1" } + } + } + ] + } +}