diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index 8e469f17b..4a7486557 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -65,6 +65,10 @@ public enum ActionType ChatTurnResume, [WireValue("chat/activityChanged")] ChatActivityChanged, + [WireValue("chat/backgroundWorkSet")] + ChatBackgroundWorkSet, + [WireValue("chat/backgroundWorkRemoved")] + ChatBackgroundWorkRemoved, [WireValue("chat/movableChanged")] ChatMovableChanged, [WireValue("chat/changesetsChanged")] @@ -1724,6 +1728,25 @@ public sealed record ChatActivityChangedAction public string? Activity { get; init; } } +/// Adds or replaces a {@link BackgroundWork} entry by `id`, independently of turn +/// state. +public sealed record ChatBackgroundWorkSetAction +{ + public ActionType Type { get; init; } + + /// The complete entry. + public required BackgroundWork Work { get; init; } +} + +/// Removes finished or no-longer-tracked background work; unknown IDs are a no-op. +public sealed record ChatBackgroundWorkRemovedAction +{ + public ActionType Type { get; init; } + + /// The {@link BackgroundWorkBase.id | id} of the entry to remove. + public required string Id { get; init; } +} + /// Whether this chat is structurally eligible to be the source of `moveChat` /// changed. /// @@ -2731,6 +2754,8 @@ public StateActionConverter() ["chat/error"] = typeof(ChatErrorAction), ["chat/turnResume"] = typeof(ChatTurnResumeAction), ["chat/activityChanged"] = typeof(ChatActivityChangedAction), + ["chat/backgroundWorkSet"] = typeof(ChatBackgroundWorkSetAction), + ["chat/backgroundWorkRemoved"] = typeof(ChatBackgroundWorkRemovedAction), ["chat/movableChanged"] = typeof(ChatMovableChangedAction), ["chat/changesetsChanged"] = typeof(ChatChangesetsChangedAction), ["chat/workingDirectorySet"] = typeof(ChatWorkingDirectorySetAction), diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs index 4811823cb..69cd3dfd0 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs @@ -76,6 +76,10 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(AutomationTriggerEventDefinition))] [JsonSerializable(typeof(AutomationTriggerKind))] [JsonSerializable(typeof(AutomationUpdateRequestedAction))] +[JsonSerializable(typeof(BackgroundShellWork))] +[JsonSerializable(typeof(BackgroundSubagentWork))] +[JsonSerializable(typeof(BackgroundWork))] +[JsonSerializable(typeof(BackgroundWorkKind))] [JsonSerializable(typeof(Changeset))] [JsonSerializable(typeof(ChangesetCapabilities))] [JsonSerializable(typeof(ChangesetClearedAction))] @@ -98,6 +102,8 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(ChangesetStatusChangedAction))] [JsonSerializable(typeof(ChangesSummary))] [JsonSerializable(typeof(ChatActivityChangedAction))] +[JsonSerializable(typeof(ChatBackgroundWorkRemovedAction))] +[JsonSerializable(typeof(ChatBackgroundWorkSetAction))] [JsonSerializable(typeof(ChatChangesetsChangedAction))] [JsonSerializable(typeof(ChatDeltaAction))] [JsonSerializable(typeof(ChatDraftChangedAction))] diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index 73079cc04..b416bdd5a 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -457,6 +457,21 @@ public enum TerminalLifecycleStatus Exited, } +/// Kind of {@link BackgroundWork}. +/// +/// This is a general/typological union (not a lifecycle), so the discriminant is +/// a `*Kind`. +[JsonConverter(typeof(WireEnumConverter))] +public enum BackgroundWorkKind +{ + /// A shell command that continues after its initiating tool call returns. + [WireValue("shell")] + Shell, + /// A subagent running in the background. + [WireValue("subagent")] + Subagent, +} + /// Discriminant for the {@link McpServerState} union. [JsonConverter(typeof(WireEnumConverter))] public enum McpServerStatus @@ -1174,6 +1189,71 @@ public sealed class ChatSummary public List? WorkingDirectories { get; set; } } +/// A shell command continuing outside its initiating tool call. Covers shells +/// tied to the agent's lifetime (attached) and shells that outlive it +/// (detached). Whether a shell is attached is provider-specific and goes in its +/// `_meta`. +public sealed record BackgroundShellWork +{ + /// Identifier of this entry, unique within the owning chat across all kinds. + /// The host derives it however it likes (for example from the kind plus the + /// agent's own task id); consumers MUST treat it as opaque. It is the key for + /// the `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert + /// convention. + public required string Id { get; init; } + + /// Human-readable label, such as the command's purpose or the subagent's name. + public required string Label { get; init; } + + /// ISO 8601 timestamp when the work started. + public required string StartedAt { get; init; } + + /// Provider-specific metadata. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public BackgroundWorkKind Kind { get; init; } + + /// Command line, displayed as plain text. + public required string Command { get; init; } + + /// Terminal carrying this shell's output. Hosts SHOULD set this whenever they + /// can show that output. Clients open it like + /// {@link ToolResultTerminalContent.resource}; `isPty` on its + /// {@link TerminalState} says whether the output is plain text. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Terminal { get; init; } +} + +/// A subagent running in the background. Its own state lives in its chat. +public sealed record BackgroundSubagentWork +{ + /// Identifier of this entry, unique within the owning chat across all kinds. + /// The host derives it however it likes (for example from the kind plus the + /// agent's own task id); consumers MUST treat it as opaque. It is the key for + /// the `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert + /// convention. + public required string Id { get; init; } + + /// Human-readable label, such as the command's purpose or the subagent's name. + public required string Label { get; init; } + + /// ISO 8601 timestamp when the work started. + public required string StartedAt { get; init; } + + /// Provider-specific metadata. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public BackgroundWorkKind Kind { get; init; } + + /// The subagent's chat: the same chat the spawning tool call's + /// {@link ToolResultSubagentContent.resource} points to. + public required string Chat { get; init; } +} + /// Full state for a single chat, loaded when a client subscribes to the chat's /// URI. /// @@ -1255,6 +1335,17 @@ public sealed class ChatState [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? Changesets { get; set; } + /// Work running in the background for this chat, such as shells and + /// subagents. Only active work is listed: hosts remove an entry once the work + /// ends. An entry may have been started by an earlier turn rather than the + /// {@link ChatState.activeTurn | activeTurn}. + /// + /// Like {@link ChatState.changesets | changesets}, this is intentionally + /// absent from {@link ChatSummary}; clients obtain it by subscribing to the + /// chat channel. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? BackgroundWork { get; set; } + /// Completed turns public required List Turns { get; set; } @@ -6193,6 +6284,33 @@ public SessionInputRequestConverter() } } +/// Work that keeps running after the tool call that started it returns and will resume the owning chat when it finishes. +[JsonConverter(typeof(BackgroundWorkConverter))] +public sealed class BackgroundWork : AhpUnion +{ + /// Creates an empty BackgroundWork (no active variant). + public BackgroundWork() { } + + /// Creates a BackgroundWork wrapping the given variant value. + public BackgroundWork(object? value) : base(value) { } +} + +/// System.Text.Json converter for the BackgroundWork discriminated union. +internal sealed class BackgroundWorkConverter : UnionConverter +{ + public BackgroundWorkConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["shell"] = typeof(BackgroundShellWork), + ["subagent"] = typeof(BackgroundSubagentWork), + }, + allowUnknown: true) + { + } +} + /// TerminalLifecycleState is the current lifecycle of a terminal process. [JsonConverter(typeof(TerminalLifecycleStateConverter))] public sealed class TerminalLifecycleState : AhpUnion diff --git a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs index f585722e0..49842274b 100644 --- a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs @@ -82,6 +82,12 @@ public static bool TryGetActionType(object action, out ActionType actionType) case ChatActivityChangedAction value: actionType = value.Type; return true; + case ChatBackgroundWorkRemovedAction value: + actionType = value.Type; + return true; + case ChatBackgroundWorkSetAction value: + actionType = value.Type; + return true; case ChatChangesetsChangedAction value: actionType = value.Type; return true; @@ -405,6 +411,8 @@ public static string GetWireName(ActionType actionType) => ActionType.ChangesetOperationStatusChanged => "changeset/operationStatusChanged", ActionType.ChangesetStatusChanged => "changeset/statusChanged", ActionType.ChatActivityChanged => "chat/activityChanged", + ActionType.ChatBackgroundWorkRemoved => "chat/backgroundWorkRemoved", + ActionType.ChatBackgroundWorkSet => "chat/backgroundWorkSet", ActionType.ChatChangesetsChanged => "chat/changesetsChanged", ActionType.ChatDelta => "chat/delta", ActionType.ChatDraftChanged => "chat/draftChanged", diff --git a/clients/dotnet/src/AgentHostProtocol/Reducers.cs b/clients/dotnet/src/AgentHostProtocol/Reducers.cs index bbd4408ca..af977f375 100644 --- a/clients/dotnet/src/AgentHostProtocol/Reducers.cs +++ b/clients/dotnet/src/AgentHostProtocol/Reducers.cs @@ -91,6 +91,14 @@ private static SessionStatus WithInputNeededStatus(SessionStatus status, List work.Value switch + { + BackgroundShellWork v => v.Id, + BackgroundSubagentWork v => v.Id, + JsonElement e when e.TryGetProperty("id", out JsonElement id) => id.GetString() ?? string.Empty, + _ => string.Empty, + }; + private static string SessionInputRequestId(SessionInputRequest req) => req.Value switch { SessionChatInputRequest v => v.Id, @@ -971,6 +979,33 @@ public static ReduceOutcome ApplyToChat(ChatState state, StateAction action) case ChatActivityChangedAction a: state.Activity = a.Activity; return ReduceOutcome.Applied; + case ChatBackgroundWorkSetAction a: + { + string workId = BackgroundWorkId(a.Work); + state.BackgroundWork ??= new List(); + int idx = state.BackgroundWork.FindIndex(w => BackgroundWorkId(w) == workId); + if (idx < 0) + { + state.BackgroundWork.Add(a.Work); + } + else + { + state.BackgroundWork[idx] = a.Work; + } + + return ReduceOutcome.Applied; + } + case ChatBackgroundWorkRemovedAction a: + { + int idx = state.BackgroundWork?.FindIndex(w => BackgroundWorkId(w) == a.Id) ?? -1; + if (idx < 0) + { + return ReduceOutcome.NoOp; + } + + state.BackgroundWork!.RemoveAt(idx); + return ReduceOutcome.Applied; + } case ChatMovableChangedAction a: state.Movable = a.Movable; return ReduceOutcome.Applied; diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 5415f49c8..ebef72956 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -323,6 +323,24 @@ func sessionInputRequestID(r ahptypes.SessionInputRequest) (string, bool) { return "", false } +func backgroundWorkID(w ahptypes.BackgroundWork) (string, bool) { + switch v := w.Value.(type) { + case *ahptypes.BackgroundShellWork: + return v.Id, true + case *ahptypes.BackgroundSubagentWork: + return v.Id, true + case *ahptypes.BackgroundWorkUnknown: + // Kinds from newer hosts still carry the common `id`, so they can be replaced and removed. + var common struct { + ID string `json:"id"` + } + if err := json.Unmarshal(v.Raw, &common); err == nil && common.ID != "" { + return common.ID, true + } + } + return "", false +} + func childCustomizationID(c ahptypes.ChildCustomization) (string, bool) { switch v := c.Value.(type) { case *ahptypes.AgentCustomization: @@ -559,6 +577,36 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R case *ahptypes.ChatActivityChangedAction: state.Activity = a.Activity return ReduceOutcomeApplied + case *ahptypes.ChatBackgroundWorkSetAction: + id, ok := backgroundWorkID(a.Work) + if !ok { + return ReduceOutcomeNoOp + } + if state.BackgroundWork == nil { + work := []ahptypes.BackgroundWork{} + state.BackgroundWork = &work + } + work := *state.BackgroundWork + for i := range work { + if got, ok := backgroundWorkID(work[i]); ok && got == id { + work[i] = a.Work + return ReduceOutcomeApplied + } + } + *state.BackgroundWork = append(work, a.Work) + return ReduceOutcomeApplied + case *ahptypes.ChatBackgroundWorkRemovedAction: + if state.BackgroundWork == nil { + return ReduceOutcomeNoOp + } + work := *state.BackgroundWork + for i := range work { + if got, ok := backgroundWorkID(work[i]); ok && got == a.Id { + *state.BackgroundWork = append(work[:i], work[i+1:]...) + return ReduceOutcomeApplied + } + } + return ReduceOutcomeNoOp case *ahptypes.ChatMovableChangedAction: movable := a.Movable state.Movable = &movable diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index 5d222d273..4a88b903b 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -45,6 +45,8 @@ const ( ActionTypeChatError ActionType = "chat/error" ActionTypeChatTurnResume ActionType = "chat/turnResume" ActionTypeChatActivityChanged ActionType = "chat/activityChanged" + ActionTypeChatBackgroundWorkSet ActionType = "chat/backgroundWorkSet" + ActionTypeChatBackgroundWorkRemoved ActionType = "chat/backgroundWorkRemoved" ActionTypeChatMovableChanged ActionType = "chat/movableChanged" ActionTypeChatChangesetsChanged ActionType = "chat/changesetsChanged" ActionTypeChatWorkingDirectorySet ActionType = "chat/workingDirectorySet" @@ -659,6 +661,21 @@ type ChatActivityChangedAction struct { Activity *string `json:"activity,omitempty"` } +// Adds or replaces a {@link BackgroundWork} entry by `id`, independently of turn +// state. +type ChatBackgroundWorkSetAction struct { + Type ActionType `json:"type"` + // The complete entry. + Work BackgroundWork `json:"work"` +} + +// Removes finished or no-longer-tracked background work; unknown IDs are a no-op. +type ChatBackgroundWorkRemovedAction struct { + Type ActionType `json:"type"` + // The {@link BackgroundWorkBase.id | id} of the entry to remove. + Id string `json:"id"` +} + // Whether this chat is structurally eligible to be the source of `moveChat` // changed. // @@ -1771,6 +1788,8 @@ func (*ChatTurnCancelledAction) isStateAction() {} func (*ChatErrorAction) isStateAction() {} func (*ChatTurnResumeAction) isStateAction() {} func (*ChatActivityChangedAction) isStateAction() {} +func (*ChatBackgroundWorkSetAction) isStateAction() {} +func (*ChatBackgroundWorkRemovedAction) isStateAction() {} func (*ChatMovableChangedAction) isStateAction() {} func (*ChatChangesetsChangedAction) isStateAction() {} func (*SessionTitleChangedAction) isStateAction() {} @@ -2022,6 +2041,18 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "chat/backgroundWorkSet": + var value ChatBackgroundWorkSetAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "chat/backgroundWorkRemoved": + var value ChatBackgroundWorkRemovedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value case "chat/movableChanged": var value ChatMovableChangedAction if err := json.Unmarshal(data, &value); err != nil { diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index fb84f5630..3ff43dff6 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -359,6 +359,19 @@ const ( TerminalLifecycleStatusExited TerminalLifecycleStatus = "exited" ) +// Kind of {@link BackgroundWork}. +// +// This is a general/typological union (not a lifecycle), so the discriminant is +// a `*Kind`. +type BackgroundWorkKind string + +const ( + // A shell command that continues after its initiating tool call returns. + BackgroundWorkKindShell BackgroundWorkKind = "shell" + // A subagent running in the background. + BackgroundWorkKindSubagent BackgroundWorkKind = "subagent" +) + // Discriminant for the {@link McpServerState} union. type McpServerStatus string @@ -1297,6 +1310,15 @@ type ChatState struct { // This catalogue is intentionally absent from {@link ChatSummary}; clients // obtain it by subscribing to the chat channel. Changesets []Changeset `json:"changesets,omitempty"` + // Work running in the background for this chat, such as shells and + // subagents. Only active work is listed: hosts remove an entry once the work + // ends. An entry may have been started by an earlier turn rather than the + // {@link ChatState.activeTurn | activeTurn}. + // + // Like {@link ChatState.changesets | changesets}, this is intentionally + // absent from {@link ChatSummary}; clients obtain it by subscribing to the + // chat channel. + BackgroundWork *[]BackgroundWork `json:"backgroundWork,omitempty"` // Completed turns Turns []Turn `json:"turns"` // Cursor for loading older completed turns into this chat state. @@ -1364,6 +1386,53 @@ type ChatSummary struct { WorkingDirectories []URI `json:"workingDirectories,omitempty"` } +// A shell command continuing outside its initiating tool call. Covers shells +// tied to the agent's lifetime (attached) and shells that outlive it +// (detached). Whether a shell is attached is provider-specific and goes in its +// `_meta`. +type BackgroundShellWork struct { + // Identifier of this entry, unique within the owning chat across all kinds. + // The host derives it however it likes (for example from the kind plus the + // agent's own task id); consumers MUST treat it as opaque. It is the key for + // the `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert + // convention. + Id string `json:"id"` + // Human-readable label, such as the command's purpose or the subagent's name. + Label string `json:"label"` + // ISO 8601 timestamp when the work started. + StartedAt string `json:"startedAt"` + // Provider-specific metadata. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` + Kind BackgroundWorkKind `json:"kind"` + // Command line, displayed as plain text. + Command string `json:"command"` + // Terminal carrying this shell's output. Hosts SHOULD set this whenever they + // can show that output. Clients open it like + // {@link ToolResultTerminalContent.resource}; `isPty` on its + // {@link TerminalState} says whether the output is plain text. + Terminal *URI `json:"terminal,omitempty"` +} + +// A subagent running in the background. Its own state lives in its chat. +type BackgroundSubagentWork struct { + // Identifier of this entry, unique within the owning chat across all kinds. + // The host derives it however it likes (for example from the kind plus the + // agent's own task id); consumers MUST treat it as opaque. It is the key for + // the `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert + // convention. + Id string `json:"id"` + // Human-readable label, such as the command's purpose or the subagent's name. + Label string `json:"label"` + // ISO 8601 timestamp when the work started. + StartedAt string `json:"startedAt"` + // Provider-specific metadata. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` + Kind BackgroundWorkKind `json:"kind"` + // The subagent's chat: the same chat the spawning tool call's + // {@link ToolResultSubagentContent.resource} points to. + Chat URI `json:"chat"` +} + // Immutable selected-text snapshot captured when a side chat is created. // // The host records this exact text when it accepts `createChat`; later changes @@ -5640,6 +5709,66 @@ func (u SessionInputRequest) MarshalJSON() ([]byte, error) { return json.Marshal(u.Value) } +// BackgroundWork is work that keeps running after the tool call that started it returns and will resume the owning chat when it finishes. +type BackgroundWork struct { + Value isBackgroundWork +} + +// isBackgroundWork is the marker interface implemented by every +// concrete variant of BackgroundWork. +type isBackgroundWork interface{ isBackgroundWork() } + +func (*BackgroundShellWork) isBackgroundWork() {} +func (*BackgroundSubagentWork) isBackgroundWork() {} + +// BackgroundWorkUnknown carries an unrecognized BackgroundWork 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 BackgroundWorkUnknown struct { + Raw json.RawMessage +} + +func (*BackgroundWorkUnknown) isBackgroundWork() {} + +// UnmarshalJSON decodes the variant indicated by the "kind" discriminator. +func (u *BackgroundWork) UnmarshalJSON(data []byte) error { + disc, _, err := readDiscriminator(data, "kind") + if err != nil { + return err + } + switch disc { + case "shell": + var value BackgroundShellWork + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "subagent": + var value BackgroundSubagentWork + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + default: + raw := make(json.RawMessage, len(data)) + copy(raw, data) + u.Value = &BackgroundWorkUnknown{Raw: raw} + } + return nil +} + +// MarshalJSON encodes the active variant back to JSON. +func (u BackgroundWork) MarshalJSON() ([]byte, error) { + if unk, ok := u.Value.(*BackgroundWorkUnknown); ok { + if len(unk.Raw) == 0 { + return []byte("null"), nil + } + return unk.Raw, nil + } + if u.Value == nil { + return []byte("null"), nil + } + return json.Marshal(u.Value) +} + // SessionOrigin is the durable origin of a session. type SessionOrigin struct { Value isSessionOrigin 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 29794deea..8fd22a745 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -11,6 +11,7 @@ import com.microsoft.agenthostprotocol.generated.* import java.time.Instant import java.time.format.DateTimeFormatterBuilder import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive // ─── Reducer Interface ────────────────────────────────────────────────────── @@ -253,6 +254,13 @@ private fun customizationId(c: Customization): String? = when (c) { is CustomizationUnknown -> null } +private fun backgroundWorkId(w: BackgroundWork): String? = when (w) { + is BackgroundWorkShell -> w.value.id + is BackgroundWorkSubagent -> w.value.id + // Kinds from newer hosts still carry the common `id`, so they can be replaced and removed. + is BackgroundWorkUnknown -> (w.raw["id"] as? JsonPrimitive)?.takeIf { it.isString }?.content +} + private fun sessionInputRequestId(r: SessionInputRequest): String? = when (r) { is SessionInputRequestChatInput -> r.value.id is SessionInputRequestToolConfirmation -> r.value.id @@ -1005,6 +1013,33 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when is StateActionChatActivityChanged -> state.copy(activity = action.value.activity) + is StateActionChatBackgroundWorkSet -> { + val work = action.value.work + val id = backgroundWorkId(work) + if (id == null) state else { + val list = state.backgroundWork ?: emptyList() + val idx = list.indexOfFirst { backgroundWorkId(it) == id } + val updated = if (idx < 0) { + list + work + } else { + list.toMutableList().also { it[idx] = work } + } + state.copy(backgroundWork = updated) + } + } + + is StateActionChatBackgroundWorkRemoved -> { + val list = state.backgroundWork + val idx = list?.indexOfFirst { backgroundWorkId(it) == action.value.id } ?: -1 + if (list == null || idx < 0) { + state + } else { + val next = list.toMutableList() + next.removeAt(idx) + state.copy(backgroundWork = next) + } + } + is StateActionChatMovableChanged -> state.copy(movable = action.value.movable) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt index d7753c5df..3607b366b 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt @@ -55,6 +55,8 @@ value class ActionType(val rawValue: String) { val CHAT_ERROR: ActionType = ActionType("chat/error") val CHAT_TURN_RESUME: ActionType = ActionType("chat/turnResume") val CHAT_ACTIVITY_CHANGED: ActionType = ActionType("chat/activityChanged") + val CHAT_BACKGROUND_WORK_SET: ActionType = ActionType("chat/backgroundWorkSet") + val CHAT_BACKGROUND_WORK_REMOVED: ActionType = ActionType("chat/backgroundWorkRemoved") val CHAT_MOVABLE_CHANGED: ActionType = ActionType("chat/movableChanged") val CHAT_CHANGESETS_CHANGED: ActionType = ActionType("chat/changesetsChanged") val CHAT_WORKING_DIRECTORY_SET: ActionType = ActionType("chat/workingDirectorySet") @@ -740,6 +742,24 @@ data class ChatActivityChangedAction( val activity: String? = null ) +@Serializable +data class ChatBackgroundWorkSetAction( + val type: ActionType, + /** + * The complete entry. + */ + val work: BackgroundWork +) + +@Serializable +data class ChatBackgroundWorkRemovedAction( + val type: ActionType, + /** + * The {@link BackgroundWorkBase.id | id} of the entry to remove. + */ + val id: String +) + @Serializable data class ChatMovableChangedAction( val type: ActionType, @@ -1668,6 +1688,8 @@ sealed interface 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 StateActionChatBackgroundWorkSet(val value: ChatBackgroundWorkSetAction) : StateAction +@JvmInline value class StateActionChatBackgroundWorkRemoved(val value: ChatBackgroundWorkRemovedAction) : StateAction @JvmInline value class StateActionChatMovableChanged(val value: ChatMovableChangedAction) : StateAction @JvmInline value class StateActionChatChangesetsChanged(val value: ChatChangesetsChangedAction) : StateAction @JvmInline value class StateActionSessionTitleChanged(val value: SessionTitleChangedAction) : StateAction @@ -1784,6 +1806,8 @@ internal object StateActionSerializer : KSerializer { "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)) + "chat/backgroundWorkSet" -> StateActionChatBackgroundWorkSet(input.json.decodeFromJsonElement(ChatBackgroundWorkSetAction.serializer(), element)) + "chat/backgroundWorkRemoved" -> StateActionChatBackgroundWorkRemoved(input.json.decodeFromJsonElement(ChatBackgroundWorkRemovedAction.serializer(), element)) "chat/movableChanged" -> StateActionChatMovableChanged(input.json.decodeFromJsonElement(ChatMovableChangedAction.serializer(), element)) "chat/changesetsChanged" -> StateActionChatChangesetsChanged(input.json.decodeFromJsonElement(ChatChangesetsChangedAction.serializer(), element)) "session/titleChanged" -> StateActionSessionTitleChanged(input.json.decodeFromJsonElement(SessionTitleChangedAction.serializer(), element)) @@ -1893,6 +1917,8 @@ internal object StateActionSerializer : KSerializer { 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 StateActionChatBackgroundWorkSet -> output.json.encodeToJsonElement(ChatBackgroundWorkSetAction.serializer(), value.value) + is StateActionChatBackgroundWorkRemoved -> output.json.encodeToJsonElement(ChatBackgroundWorkRemovedAction.serializer(), value.value) is StateActionChatMovableChanged -> output.json.encodeToJsonElement(ChatMovableChangedAction.serializer(), value.value) is StateActionChatChangesetsChanged -> output.json.encodeToJsonElement(ChatChangesetsChangedAction.serializer(), value.value) is StateActionSessionTitleChanged -> output.json.encodeToJsonElement(SessionTitleChangedAction.serializer(), value.value) 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 8fb7f0a42..d85f16337 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 @@ -766,6 +766,37 @@ enum class TerminalLifecycleStatus { EXITED } +/** + * Kind of {@link BackgroundWork}. + * + * This is a general/typological union (not a lifecycle), so the discriminant is + * a `*Kind`. + */ +@Serializable(with = BackgroundWorkKindSerializer::class) +@JvmInline +value class BackgroundWorkKind(val rawValue: String) { + companion object { + /** + * A shell command that continues after its initiating tool call returns. + */ + val SHELL: BackgroundWorkKind = BackgroundWorkKind("shell") + /** + * A subagent running in the background. + */ + val SUBAGENT: BackgroundWorkKind = BackgroundWorkKind("subagent") + } +} + +internal object BackgroundWorkKindSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("BackgroundWorkKind", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: BackgroundWorkKind) { + encoder.encodeString(value.rawValue) + } + override fun deserialize(decoder: Decoder): BackgroundWorkKind = + BackgroundWorkKind(decoder.decodeString()) +} + /** * Discriminant for the {@link McpServerState} union. */ @@ -1665,6 +1696,17 @@ data class ChatState( * obtain it by subscribing to the chat channel. */ val changesets: List? = null, + /** + * Work running in the background for this chat, such as shells and + * subagents. Only active work is listed: hosts remove an entry once the work + * ends. An entry may have been started by an earlier turn rather than the + * {@link ChatState.activeTurn | activeTurn}. + * + * Like {@link ChatState.changesets | changesets}, this is intentionally + * absent from {@link ChatSummary}; clients obtain it by subscribing to the + * chat channel. + */ + val backgroundWork: List? = null, /** * Completed turns */ @@ -1956,6 +1998,74 @@ data class SessionActiveClient( val customizations: List? = null ) +@Serializable +data class BackgroundShellWork( + /** + * Identifier of this entry, unique within the owning chat across all kinds. + * The host derives it however it likes (for example from the kind plus the + * agent's own task id); consumers MUST treat it as opaque. It is the key for + * the `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert + * convention. + */ + val id: String, + /** + * Human-readable label, such as the command's purpose or the subagent's name. + */ + val label: String, + /** + * ISO 8601 timestamp when the work started. + */ + val startedAt: String, + /** + * Provider-specific metadata. + */ + @SerialName("_meta") + val meta: Map? = null, + val kind: BackgroundWorkKind, + /** + * Command line, displayed as plain text. + */ + val command: String, + /** + * Terminal carrying this shell's output. Hosts SHOULD set this whenever they + * can show that output. Clients open it like + * {@link ToolResultTerminalContent.resource}; `isPty` on its + * {@link TerminalState} says whether the output is plain text. + */ + val terminal: String? = null +) + +@Serializable +data class BackgroundSubagentWork( + /** + * Identifier of this entry, unique within the owning chat across all kinds. + * The host derives it however it likes (for example from the kind plus the + * agent's own task id); consumers MUST treat it as opaque. It is the key for + * the `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert + * convention. + */ + val id: String, + /** + * Human-readable label, such as the command's purpose or the subagent's name. + */ + val label: String, + /** + * ISO 8601 timestamp when the work started. + */ + val startedAt: String, + /** + * Provider-specific metadata. + */ + @SerialName("_meta") + val meta: Map? = null, + val kind: BackgroundWorkKind, + /** + * The subagent's chat: the same chat the spawning tool call's + * {@link ToolResultSubagentContent.resource} points to. + */ + val chat: String +) + @Serializable data class SessionChatInputRequest( /** @@ -6897,6 +7007,54 @@ internal object SessionInputRequestSerializer : KSerializer output.encodeJsonElement(element) } } +@Serializable(with = BackgroundWorkSerializer::class) +sealed interface BackgroundWork + +@JvmInline +value class BackgroundWorkShell(val value: BackgroundShellWork) : BackgroundWork +@JvmInline +value class BackgroundWorkSubagent(val value: BackgroundSubagentWork) : BackgroundWork +/** + * Forward-compat catch-all for unknown BackgroundWork discriminators. + * + * Older clients may receive newer wire variants they don't recognise; capturing + * the raw `JsonObject` lets such payloads round-trip through the client unchanged. + * Reducers handle this variant conservatively on a per-union basis (typically + * as a no-op, but see `Reducers.kt` for the exact treatment). + */ +@JvmInline +value class BackgroundWorkUnknown(val raw: JsonObject) : BackgroundWork + +internal object BackgroundWorkSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("BackgroundWork") + + override fun deserialize(decoder: Decoder): BackgroundWork { + val input = decoder as? JsonDecoder + ?: error("BackgroundWork can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for BackgroundWork") + val discriminant = (obj["kind"] as? JsonPrimitive)?.content + ?: return BackgroundWorkUnknown(obj) + return when (discriminant) { + "shell" -> BackgroundWorkShell(input.json.decodeFromJsonElement(BackgroundShellWork.serializer(), element)) + "subagent" -> BackgroundWorkSubagent(input.json.decodeFromJsonElement(BackgroundSubagentWork.serializer(), element)) + else -> BackgroundWorkUnknown(obj) + } + } + + override fun serialize(encoder: Encoder, value: BackgroundWork) { + val output = encoder as? JsonEncoder + ?: error("BackgroundWork can only be serialized to JSON") + val element: JsonElement = when (value) { + is BackgroundWorkShell -> output.json.encodeToJsonElement(BackgroundShellWork.serializer(), value.value) + is BackgroundWorkSubagent -> output.json.encodeToJsonElement(BackgroundSubagentWork.serializer(), value.value) + is BackgroundWorkUnknown -> value.raw + } + output.encodeJsonElement(element) + } +} @Serializable(with = SessionOriginSerializer::class) sealed interface SessionOrigin diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 27bba2580..ebf56f342 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -15,14 +15,15 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; use crate::state::{ AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, - ChangesSummary, Changeset, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, - ChangesetStatus, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, - ChatOrigin, ChatSummary, ConfirmationOption, ContentRef, Customization, - CustomizationEnablement, ErrorInfo, ErrorResponsePart, FileEditCollection, McpAuthRequirement, - McpServerState, Message, ModelSelection, PendingMessageKind, ResponsePart, SessionActiveClient, - SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, - ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributor, ToolCallResult, - ToolCallRiskAssessment, ToolDefinition, ToolInput, ToolResultContent, Turn, UsageInfo, + BackgroundWork, ChangesSummary, Changeset, ChangesetFile, ChangesetOperation, + ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, ChatInputRequest, + ChatInputResponseKind, ChatInteractivity, ChatOrigin, ChatSummary, ConfirmationOption, + ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, + FileEditCollection, McpAuthRequirement, McpServerState, Message, ModelSelection, + PendingMessageKind, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, + TerminalClaim, TerminalInfo, TextRange, ToolCallCancellationReason, ToolCallConfirmationReason, + ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolDefinition, ToolInput, + ToolResultContent, Turn, UsageInfo, }; // ─── ActionType ────────────────────────────────────────────────────── @@ -55,6 +56,8 @@ pub enum ActionType { ChatError, ChatTurnResume, ChatActivityChanged, + ChatBackgroundWorkSet, + ChatBackgroundWorkRemoved, ChatMovableChanged, ChatChangesetsChanged, ChatWorkingDirectorySet, @@ -174,6 +177,10 @@ impl serde::Serialize for ActionType { Self::ChatError => serializer.serialize_str("chat/error"), Self::ChatTurnResume => serializer.serialize_str("chat/turnResume"), Self::ChatActivityChanged => serializer.serialize_str("chat/activityChanged"), + Self::ChatBackgroundWorkSet => serializer.serialize_str("chat/backgroundWorkSet"), + Self::ChatBackgroundWorkRemoved => { + serializer.serialize_str("chat/backgroundWorkRemoved") + } Self::ChatMovableChanged => serializer.serialize_str("chat/movableChanged"), Self::ChatChangesetsChanged => serializer.serialize_str("chat/changesetsChanged"), Self::ChatWorkingDirectorySet => serializer.serialize_str("chat/workingDirectorySet"), @@ -341,6 +348,8 @@ impl<'de> serde::Deserialize<'de> for ActionType { "chat/error" => Self::ChatError, "chat/turnResume" => Self::ChatTurnResume, "chat/activityChanged" => Self::ChatActivityChanged, + "chat/backgroundWorkSet" => Self::ChatBackgroundWorkSet, + "chat/backgroundWorkRemoved" => Self::ChatBackgroundWorkRemoved, "chat/movableChanged" => Self::ChatMovableChanged, "chat/changesetsChanged" => Self::ChatChangesetsChanged, "chat/workingDirectorySet" => Self::ChatWorkingDirectorySet, @@ -1076,6 +1085,23 @@ pub struct ChatActivityChangedAction { pub activity: Option, } +/// Adds or replaces a {@link BackgroundWork} entry by `id`, independently of turn +/// state. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatBackgroundWorkSetAction { + /// The complete entry. + pub work: BackgroundWork, +} + +/// Removes finished or no-longer-tracked background work; unknown IDs are a no-op. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatBackgroundWorkRemovedAction { + /// The {@link BackgroundWorkBase.id | id} of the entry to remove. + pub id: String, +} + /// Whether this chat is structurally eligible to be the source of `moveChat` /// changed. /// @@ -2353,6 +2379,10 @@ pub enum StateAction { ChatTurnResume(ChatTurnResumeAction), #[serde(rename = "chat/activityChanged")] ChatActivityChanged(ChatActivityChangedAction), + #[serde(rename = "chat/backgroundWorkSet")] + ChatBackgroundWorkSet(ChatBackgroundWorkSetAction), + #[serde(rename = "chat/backgroundWorkRemoved")] + ChatBackgroundWorkRemoved(ChatBackgroundWorkRemovedAction), #[serde(rename = "chat/movableChanged")] ChatMovableChanged(ChatMovableChangedAction), #[serde(rename = "chat/changesetsChanged")] diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 99547d09d..427c9f86d 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -994,6 +994,47 @@ pub enum TerminalLifecycleStatus { Exited, } +/// Kind of {@link BackgroundWork}. +/// +/// This is a general/typological union (not a lifecycle), so the discriminant is +/// a `*Kind`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum BackgroundWorkKind { + /// A shell command that continues after its initiating tool call returns. + Shell, + /// A subagent running in the background. + Subagent, + /// Unknown raw value from a newer protocol version, preserved verbatim. + Unknown(String), +} + +impl serde::Serialize for BackgroundWorkKind { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Shell => serializer.serialize_str("shell"), + Self::Subagent => serializer.serialize_str("subagent"), + Self::Unknown(value) => serializer.serialize_str(value), + } + } +} + +impl<'de> serde::Deserialize<'de> for BackgroundWorkKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = ::deserialize(deserializer)?; + Ok(match raw.as_str() { + "shell" => Self::Shell, + "subagent" => Self::Subagent, + _ => Self::Unknown(raw), + }) + } +} + /// Discriminant for the {@link McpServerState} union. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum McpServerStatus { @@ -1935,6 +1976,16 @@ pub struct ChatState { /// obtain it by subscribing to the chat channel. #[serde(default, skip_serializing_if = "Option::is_none")] pub changesets: Option>, + /// Work running in the background for this chat, such as shells and + /// subagents. Only active work is listed: hosts remove an entry once the work + /// ends. An entry may have been started by an earlier turn rather than the + /// {@link ChatState.activeTurn | activeTurn}. + /// + /// Like {@link ChatState.changesets | changesets}, this is intentionally + /// absent from {@link ChatSummary}; clients obtain it by subscribing to the + /// chat channel. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background_work: Option>, /// Completed turns pub turns: Vec, /// Cursor for loading older completed turns into this chat state. @@ -2016,6 +2067,58 @@ pub struct ChatSummary { pub working_directories: Option>, } +/// A shell command continuing outside its initiating tool call. Covers shells +/// tied to the agent's lifetime (attached) and shells that outlive it +/// (detached). Whether a shell is attached is provider-specific and goes in its +/// `_meta`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BackgroundShellWork { + /// Identifier of this entry, unique within the owning chat across all kinds. + /// The host derives it however it likes (for example from the kind plus the + /// agent's own task id); consumers MUST treat it as opaque. It is the key for + /// the `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert + /// convention. + pub id: String, + /// Human-readable label, such as the command's purpose or the subagent's name. + pub label: String, + /// ISO 8601 timestamp when the work started. + pub started_at: String, + /// Provider-specific metadata. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Command line, displayed as plain text. + pub command: String, + /// Terminal carrying this shell's output. Hosts SHOULD set this whenever they + /// can show that output. Clients open it like + /// {@link ToolResultTerminalContent.resource}; `isPty` on its + /// {@link TerminalState} says whether the output is plain text. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal: Option, +} + +/// A subagent running in the background. Its own state lives in its chat. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BackgroundSubagentWork { + /// Identifier of this entry, unique within the owning chat across all kinds. + /// The host derives it however it likes (for example from the kind plus the + /// agent's own task id); consumers MUST treat it as opaque. It is the key for + /// the `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert + /// convention. + pub id: String, + /// Human-readable label, such as the command's purpose or the subagent's name. + pub label: String, + /// ISO 8601 timestamp when the work started. + pub started_at: String, + /// Provider-specific metadata. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// The subagent's chat: the same chat the spawning tool call's + /// {@link ToolResultSubagentContent.resource} points to. + pub chat: Uri, +} + /// Immutable selected-text snapshot captured when a side chat is created. /// /// The host records this exact text when it accepts `createChat`; later changes @@ -6206,6 +6309,19 @@ pub enum SessionInputRequest { #[serde(untagged)] Unknown(serde_json::Value), } +/// Work that keeps running after the tool call that started it returns and will resume the owning chat when it finishes. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum BackgroundWork { + #[serde(rename = "shell")] + Shell(BackgroundShellWork), + #[serde(rename = "subagent")] + Subagent(BackgroundSubagentWork), + /// Unknown or future variant — preserved as raw JSON for round-trip fidelity. + /// Reducers treat this as a no-op. + #[serde(untagged)] + Unknown(serde_json::Value), +} /// Durable origin of a session. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 71a49d699..76602c966 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -57,12 +57,12 @@ use ahp_types::actions::{ ChatTurnStartedAction, StateAction, }; use ahp_types::state::{ - ActiveTurn, AnnotationsState, AutomationRunState, AutomationState, ChangesetOperationStatus, - ChangesetState, ChangesetStatus, ChatInputRequest, ChatState, ChildCustomization, - ConfirmationOption, Customization, CustomizationEnablement, ErrorResponsePart, - InputRequestResponsePart, McpServerCustomization, McpServerStartingState, McpServerState, - McpServerStoppedState, PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, - RootState, SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, + ActiveTurn, AnnotationsState, AutomationRunState, AutomationState, BackgroundWork, + ChangesetOperationStatus, ChangesetState, ChangesetStatus, ChatInputRequest, ChatState, + ChildCustomization, ConfirmationOption, Customization, CustomizationEnablement, + ErrorResponsePart, InputRequestResponsePart, McpServerCustomization, McpServerStartingState, + McpServerState, McpServerStoppedState, PendingMessage, PendingMessageKind, ResourceWatchState, + ResponsePart, RootState, SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, TerminalCommandPart, TerminalContentPart, TerminalExitedLifecycleState, TerminalLifecycleState, TerminalState, TerminalUnclassifiedPart, ToolCallAuthRequiredState, ToolCallCancellationReason, ToolCallCancelledState, ToolCallCompletedState, ToolCallConfirmationReason, @@ -483,6 +483,14 @@ fn session_input_request_id(r: &SessionInputRequest) -> Option<&str> { } } +fn background_work_id(w: &BackgroundWork) -> Option<&str> { + match w { + BackgroundWork::Shell(x) => Some(x.id.as_str()), + BackgroundWork::Subagent(x) => Some(x.id.as_str()), + BackgroundWork::Unknown(v) => v.get("id").and_then(serde_json::Value::as_str), + } +} + fn child_id_of(c: &ChildCustomization) -> Option<&str> { match c { ChildCustomization::Agent(x) => Some(x.id.as_str()), @@ -1146,6 +1154,34 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu state.activity = a.activity.clone(); ReduceOutcome::Applied } + StateAction::ChatBackgroundWorkSet(a) => { + let Some(action_id) = background_work_id(&a.work) else { + return ReduceOutcome::NoOp; + }; + let list = state.background_work.get_or_insert_with(Vec::new); + if let Some(idx) = list + .iter() + .position(|w| background_work_id(w) == Some(action_id)) + { + list[idx] = a.work.clone(); + } else { + list.push(a.work.clone()); + } + ReduceOutcome::Applied + } + StateAction::ChatBackgroundWorkRemoved(a) => { + let Some(list) = state.background_work.as_mut() else { + return ReduceOutcome::NoOp; + }; + let Some(idx) = list + .iter() + .position(|w| background_work_id(w) == Some(a.id.as_str())) + else { + return ReduceOutcome::NoOp; + }; + list.remove(idx); + ReduceOutcome::Applied + } StateAction::ChatMovableChanged(a) => { state.movable = Some(a.movable); ReduceOutcome::Applied @@ -2252,6 +2288,7 @@ mod tests { title: String::new(), status: SessionStatus::Idle.bits(), activity: None, + background_work: None, modified_at: "1970-01-01T00:00:00.000Z".into(), changes: None, origin: None, diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index f4b817dd4..991813b78 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -32,6 +32,8 @@ public enum ActionType: Codable, Sendable, Equatable { case chatError case chatTurnResume case chatActivityChanged + case chatBackgroundWorkSet + case chatBackgroundWorkRemoved case chatMovableChanged case chatChangesetsChanged case chatWorkingDirectorySet @@ -140,6 +142,8 @@ public enum ActionType: Codable, Sendable, Equatable { case "chat/error": self = .chatError case "chat/turnResume": self = .chatTurnResume case "chat/activityChanged": self = .chatActivityChanged + case "chat/backgroundWorkSet": self = .chatBackgroundWorkSet + case "chat/backgroundWorkRemoved": self = .chatBackgroundWorkRemoved case "chat/movableChanged": self = .chatMovableChanged case "chat/changesetsChanged": self = .chatChangesetsChanged case "chat/workingDirectorySet": self = .chatWorkingDirectorySet @@ -248,6 +252,8 @@ public enum ActionType: Codable, Sendable, Equatable { case .chatError: try container.encode("chat/error") case .chatTurnResume: try container.encode("chat/turnResume") case .chatActivityChanged: try container.encode("chat/activityChanged") + case .chatBackgroundWorkSet: try container.encode("chat/backgroundWorkSet") + case .chatBackgroundWorkRemoved: try container.encode("chat/backgroundWorkRemoved") case .chatMovableChanged: try container.encode("chat/movableChanged") case .chatChangesetsChanged: try container.encode("chat/changesetsChanged") case .chatWorkingDirectorySet: try container.encode("chat/workingDirectorySet") @@ -1205,6 +1211,34 @@ public struct ChatActivityChangedAction: Codable, Sendable { } } +public struct ChatBackgroundWorkSetAction: Codable, Sendable { + public var type: ActionType + /// The complete entry. + public var work: BackgroundWork + + public init( + type: ActionType, + work: BackgroundWork + ) { + self.type = type + self.work = work + } +} + +public struct ChatBackgroundWorkRemovedAction: Codable, Sendable { + public var type: ActionType + /// The {@link BackgroundWorkBase.id | id} of the entry to remove. + public var id: String + + public init( + type: ActionType, + id: String + ) { + self.type = type + self.id = id + } +} + public struct ChatMovableChangedAction: Codable, Sendable { public var type: ActionType /// Whether this chat is structurally eligible to be moved. @@ -2518,6 +2552,8 @@ public enum StateAction: Codable, Sendable { case chatError(ChatErrorAction) case chatTurnResume(ChatTurnResumeAction) case chatActivityChanged(ChatActivityChangedAction) + case chatBackgroundWorkSet(ChatBackgroundWorkSetAction) + case chatBackgroundWorkRemoved(ChatBackgroundWorkRemovedAction) case chatMovableChanged(ChatMovableChangedAction) case chatChangesetsChanged(ChatChangesetsChangedAction) case sessionTitleChanged(SessionTitleChangedAction) @@ -2657,6 +2693,10 @@ public enum StateAction: Codable, Sendable { self = .chatTurnResume(try ChatTurnResumeAction(from: decoder)) case "chat/activityChanged": self = .chatActivityChanged(try ChatActivityChangedAction(from: decoder)) + case "chat/backgroundWorkSet": + self = .chatBackgroundWorkSet(try ChatBackgroundWorkSetAction(from: decoder)) + case "chat/backgroundWorkRemoved": + self = .chatBackgroundWorkRemoved(try ChatBackgroundWorkRemovedAction(from: decoder)) case "chat/movableChanged": self = .chatMovableChanged(try ChatMovableChangedAction(from: decoder)) case "chat/changesetsChanged": @@ -2840,6 +2880,8 @@ public enum StateAction: Codable, Sendable { case .chatError(let v): try v.encode(to: encoder) case .chatTurnResume(let v): try v.encode(to: encoder) case .chatActivityChanged(let v): try v.encode(to: encoder) + case .chatBackgroundWorkSet(let v): try v.encode(to: encoder) + case .chatBackgroundWorkRemoved(let v): try v.encode(to: encoder) case .chatMovableChanged(let v): try v.encode(to: encoder) case .chatChangesetsChanged(let v): try v.encode(to: encoder) case .sessionTitleChanged(let v): try v.encode(to: encoder) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 6293a123b..8abefa6b4 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -760,6 +760,38 @@ public enum TerminalLifecycleStatus: String, Codable, Sendable { case exited = "exited" } +/// Kind of {@link BackgroundWork}. +/// +/// This is a general/typological union (not a lifecycle), so the discriminant is +/// a `*Kind`. +public enum BackgroundWorkKind: Codable, Sendable, Equatable { + /// A shell command that continues after its initiating tool call returns. + case shell + /// A subagent running in the background. + case subagent + /// Unknown raw value from a newer protocol version, preserved verbatim. + case unknown(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + switch raw { + case "shell": self = .shell + case "subagent": self = .subagent + default: self = .unknown(raw) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .shell: try container.encode("shell") + case .subagent: try container.encode("subagent") + case .unknown(let raw): try container.encode(raw) + } + } +} + /// Discriminant for the {@link McpServerState} union. public enum McpServerStatus: Codable, Sendable, Equatable { /// Server has been registered but is not yet running. @@ -1674,6 +1706,15 @@ public struct ChatState: Codable, Sendable { /// This catalogue is intentionally absent from {@link ChatSummary}; clients /// obtain it by subscribing to the chat channel. public var changesets: [Changeset]? + /// Work running in the background for this chat, such as shells and + /// subagents. Only active work is listed: hosts remove an entry once the work + /// ends. An entry may have been started by an earlier turn rather than the + /// {@link ChatState.activeTurn | activeTurn}. + /// + /// Like {@link ChatState.changesets | changesets}, this is intentionally + /// absent from {@link ChatSummary}; clients obtain it by subscribing to the + /// chat channel. + public var backgroundWork: [BackgroundWork]? /// Completed turns public var turns: [Turn] /// Cursor for loading older completed turns into this chat state. @@ -1716,6 +1757,7 @@ public struct ChatState: Codable, Sendable { case interactivity case workingDirectories case changesets + case backgroundWork case turns case turnsNextCursor case activeTurn @@ -1737,6 +1779,7 @@ public struct ChatState: Codable, Sendable { interactivity: ChatInteractivity? = nil, workingDirectories: [String]? = nil, changesets: [Changeset]? = nil, + backgroundWork: [BackgroundWork]? = nil, turns: [Turn], turnsNextCursor: String? = nil, activeTurn: ActiveTurn? = nil, @@ -1756,6 +1799,7 @@ public struct ChatState: Codable, Sendable { self.interactivity = interactivity self.workingDirectories = workingDirectories self.changesets = changesets + self.backgroundWork = backgroundWork self.turns = turns self.turnsNextCursor = turnsNextCursor self.activeTurn = activeTurn @@ -2044,6 +2088,101 @@ public struct SessionActiveClient: Codable, Sendable { } } +public struct BackgroundShellWork: Codable, Sendable { + /// Identifier of this entry, unique within the owning chat across all kinds. + /// The host derives it however it likes (for example from the kind plus the + /// agent's own task id); consumers MUST treat it as opaque. It is the key for + /// the `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert + /// convention. + public var id: String + /// Human-readable label, such as the command's purpose or the subagent's name. + public var label: String + /// ISO 8601 timestamp when the work started. + public var startedAt: String + /// Provider-specific metadata. + public var meta: [String: AnyCodable]? + public var kind: BackgroundWorkKind + /// Command line, displayed as plain text. + public var command: String + /// Terminal carrying this shell's output. Hosts SHOULD set this whenever they + /// can show that output. Clients open it like + /// {@link ToolResultTerminalContent.resource}; `isPty` on its + /// {@link TerminalState} says whether the output is plain text. + public var terminal: String? + + enum CodingKeys: String, CodingKey { + case id + case label + case startedAt + case meta = "_meta" + case kind + case command + case terminal + } + + public init( + id: String, + label: String, + startedAt: String, + meta: [String: AnyCodable]? = nil, + kind: BackgroundWorkKind, + command: String, + terminal: String? = nil + ) { + self.id = id + self.label = label + self.startedAt = startedAt + self.meta = meta + self.kind = kind + self.command = command + self.terminal = terminal + } +} + +public struct BackgroundSubagentWork: Codable, Sendable { + /// Identifier of this entry, unique within the owning chat across all kinds. + /// The host derives it however it likes (for example from the kind plus the + /// agent's own task id); consumers MUST treat it as opaque. It is the key for + /// the `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert + /// convention. + public var id: String + /// Human-readable label, such as the command's purpose or the subagent's name. + public var label: String + /// ISO 8601 timestamp when the work started. + public var startedAt: String + /// Provider-specific metadata. + public var meta: [String: AnyCodable]? + public var kind: BackgroundWorkKind + /// The subagent's chat: the same chat the spawning tool call's + /// {@link ToolResultSubagentContent.resource} points to. + public var chat: String + + enum CodingKeys: String, CodingKey { + case id + case label + case startedAt + case meta = "_meta" + case kind + case chat + } + + public init( + id: String, + label: String, + startedAt: String, + meta: [String: AnyCodable]? = nil, + kind: BackgroundWorkKind, + chat: String + ) { + self.id = id + self.label = label + self.startedAt = startedAt + self.meta = meta + self.kind = kind + self.chat = chat + } +} + public struct SessionChatInputRequest: Codable, Sendable { /// Stable key for this entry, unique within the session's /// {@link SessionState.inputNeeded} list. The host derives it however it likes @@ -7734,6 +7873,41 @@ public enum SessionInputRequest: Codable, Sendable { } } } +public enum BackgroundWork: Codable, Sendable { + case shell(BackgroundShellWork) + case subagent(BackgroundSubagentWork) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) + + private enum DiscriminantKey: String, CodingKey { + case discriminant = "kind" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + guard let discriminant = try container.decodeIfPresent(String.self, forKey: .discriminant) else { + self = .unknown(try AnyCodable(from: decoder)) + return + } + switch discriminant { + case "shell": + self = .shell(try BackgroundShellWork(from: decoder)) + case "subagent": + self = .subagent(try BackgroundSubagentWork(from: decoder)) + default: + self = .unknown(try AnyCodable(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .shell(let value): try value.encode(to: encoder) + case .subagent(let value): try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) + } + } +} public enum SessionOrigin: Codable, Sendable { case automation(AutomationSessionOrigin) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 87f057534..6f4ef620c 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -72,6 +72,16 @@ private func refineToolCallContributor(_ current: ToolCallContributor?, _ next: return next } +/// Extracts the stable `id` of background work, including kinds from newer hosts. +private func backgroundWorkID(_ w: BackgroundWork) -> String? { + switch w { + case .shell(let x): return x.id + case .subagent(let x): return x.id + // Kinds from newer hosts still carry the common `id`, so they can be replaced and removed. + case .unknown(let raw): return (raw.value as? [String: Any])?["id"] as? String + } +} + /// Extracts the stable `id` of a session input request, or `nil` for unknown variants. private func sessionInputRequestID(_ r: SessionInputRequest) -> String? { switch r { @@ -204,6 +214,24 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { next.activity = a.activity return next + case .chatBackgroundWorkSet(let a): + guard let id = backgroundWorkID(a.work) else { return state } + var next = state + var work = state.backgroundWork ?? [] + if let idx = work.firstIndex(where: { backgroundWorkID($0) == id }) { + work[idx] = a.work + } else { + work.append(a.work) + } + next.backgroundWork = work + return next + + case .chatBackgroundWorkRemoved(let a): + guard let idx = state.backgroundWork?.firstIndex(where: { backgroundWorkID($0) == a.id }) else { return state } + var next = state + next.backgroundWork?.remove(at: idx) + return next + case .chatMovableChanged(let a): var next = state next.movable = a.movable diff --git a/docs/.changes/20260929-chat-background-work.json b/docs/.changes/20260929-chat-background-work.json new file mode 100644 index 000000000..479c8be16 --- /dev/null +++ b/docs/.changes/20260929-chat-background-work.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "Expose chat-owned background work (background shells and subagents) in chat state, with `chat/backgroundWorkSet` and `chat/backgroundWorkRemoved` actions independent of turn lifetime." +} diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index 5cdd26791..03d6874e3 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -141,6 +141,31 @@ For example, `(status & SessionStatus.InProgress) !== 0` is true for both `InPro Subscribable on a [Chat Channel](/specification/chat-channel) at `ahp-chat:/`. A session is a catalog of chats (`SessionState.chats`); each chat carries the per-conversation state — the turn history, the active turn and its streaming response parts (including live input requests), tool calls, steering/queued messages, and the user's in-progress draft. A session starts with a default chat (`SessionState.defaultChat`); hosts advertising the `multipleChats` capability let clients open more via `createChat`. +`backgroundWork` lists work running in the background for the chat, such as shells +and subagents. +Hosts publish complete entries with `chat/backgroundWorkSet` and remove them with +`chat/backgroundWorkRemoved` when they finish or are no longer tracked. Entry IDs are +opaque, unique within a chat across all kinds, and scoped to that chat. Like +`changesets`, the list is not mirrored into `SessionState.chats`; clients read it by +subscribing to the chat. + +Each entry has a `kind`, a `label`, and a start time. Every entry is unfinished work; +hosts remove entries when the work finishes rather than marking them done. A shell +entry adds its plain-text command and, when the host has one, the terminal carrying +its output. Shells can be tied to the agent's lifetime (attached) or outlive it +(detached); that distinction is provider-specific and goes in the shell's `_meta`. A +subagent entry points to the subagent's own chat instead of repeating its state. The +kind set is non-exhaustive: clients should keep entries of unknown kinds and may +render them from the common fields. Other provider-specific details also belong in +`_meta`. + +The collection survives turn completion, cancellation, steering, and history +truncation; those actions do not establish whether the work has stopped. Hosts must +reconcile the runtime's current inventory after restoring a chat, rather than replaying +historical work as running. A missing collection means no inventory has been +published; an empty collection contains no active work. This metadata does not +provide process controls or a new turn-completion rule. + ```typescript ChatState { // Chat summary fields, inlined directly (mirrored into SessionState.chats) diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 61ca03d6e..45d46563e 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -1367,6 +1367,40 @@ "type" ] }, + "ChatBackgroundWorkSetAction": { + "type": "object", + "description": "Adds or replaces a {@link BackgroundWork} entry by `id`, independently of turn\nstate.", + "properties": { + "type": { + "const": "chat/backgroundWorkSet" + }, + "work": { + "$ref": "#/$defs/BackgroundWork", + "description": "The complete entry." + } + }, + "required": [ + "type", + "work" + ] + }, + "ChatBackgroundWorkRemovedAction": { + "type": "object", + "description": "Removes finished or no-longer-tracked background work; unknown IDs are a no-op.", + "properties": { + "type": { + "const": "chat/backgroundWorkRemoved" + }, + "id": { + "type": "string", + "description": "The {@link BackgroundWorkBase.id | id} of the entry to remove." + } + }, + "required": [ + "type", + "id" + ] + }, "ChatMovableChangedAction": { "type": "object", "description": "Whether this chat is structurally eligible to be the source of `moveChat`\nchanged.\n\nThe host is authoritative and MUST also update the owning session's chat\ncatalog with `session/chatUpdated` so `ChatSummary.movable` stays in sync.\nA chat referenced by its owning session's `defaultChat` MUST always carry\n`movable: false`.", @@ -2506,6 +2540,12 @@ { "$ref": "#/$defs/ChatActivityChangedAction" }, + { + "$ref": "#/$defs/ChatBackgroundWorkSetAction" + }, + { + "$ref": "#/$defs/ChatBackgroundWorkRemovedAction" + }, { "$ref": "#/$defs/ChatMovableChangedAction" }, @@ -5004,6 +5044,13 @@ }, "description": "Catalogue of changesets the server can produce for this chat. Each entry\nadvertises a subscribable view of file changes scoped to the chat's\neffective working directories and the URI template the client expands\nbefore subscribing. See {@link Changeset} for the full shape and\n{@link /guide/changesets | Changesets} for an overview of the model.\n\nThis catalogue is intentionally absent from {@link ChatSummary}; clients\nobtain it by subscribing to the chat channel." }, + "backgroundWork": { + "type": "array", + "items": { + "$ref": "#/$defs/BackgroundWork" + }, + "description": "Work running in the background for this chat, such as shells and\nsubagents. Only active work is listed: hosts remove an entry once the work\nends. An entry may have been started by an earlier turn rather than the\n{@link ChatState.activeTurn | activeTurn}.\n\nLike {@link ChatState.changesets | changesets}, this is intentionally\nabsent from {@link ChatSummary}; clients obtain it by subscribing to the\nchat channel." + }, "turns": { "type": "array", "items": { @@ -5103,6 +5150,112 @@ "modifiedAt" ] }, + "BackgroundWorkBase": { + "type": "object", + "description": "Fields common to every {@link BackgroundWork} variant.", + "properties": { + "id": { + "type": "string", + "description": "Identifier of this entry, unique within the owning chat across all kinds.\nThe host derives it however it likes (for example from the kind plus the\nagent's own task id); consumers MUST treat it as opaque. It is the key for\nthe `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert\nconvention." + }, + "label": { + "type": "string", + "description": "Human-readable label, such as the command's purpose or the subagent's name." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the work started." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata." + } + }, + "required": [ + "id", + "label", + "startedAt" + ] + }, + "BackgroundShellWork": { + "type": "object", + "description": "A shell command continuing outside its initiating tool call. Covers shells\ntied to the agent's lifetime (attached) and shells that outlive it\n(detached). Whether a shell is attached is provider-specific and goes in its\n`_meta`.", + "properties": { + "id": { + "type": "string", + "description": "Identifier of this entry, unique within the owning chat across all kinds.\nThe host derives it however it likes (for example from the kind plus the\nagent's own task id); consumers MUST treat it as opaque. It is the key for\nthe `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert\nconvention." + }, + "label": { + "type": "string", + "description": "Human-readable label, such as the command's purpose or the subagent's name." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the work started." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata." + }, + "kind": { + "const": "shell" + }, + "command": { + "type": "string", + "description": "Command line, displayed as plain text." + }, + "terminal": { + "$ref": "#/$defs/URI", + "description": "Terminal carrying this shell's output. Hosts SHOULD set this whenever they\ncan show that output. Clients open it like\n{@link ToolResultTerminalContent.resource}; `isPty` on its\n{@link TerminalState} says whether the output is plain text." + } + }, + "required": [ + "id", + "label", + "startedAt", + "kind", + "command" + ] + }, + "BackgroundSubagentWork": { + "type": "object", + "description": "A subagent running in the background. Its own state lives in its chat.", + "properties": { + "id": { + "type": "string", + "description": "Identifier of this entry, unique within the owning chat across all kinds.\nThe host derives it however it likes (for example from the kind plus the\nagent's own task id); consumers MUST treat it as opaque. It is the key for\nthe `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert\nconvention." + }, + "label": { + "type": "string", + "description": "Human-readable label, such as the command's purpose or the subagent's name." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the work started." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata." + }, + "kind": { + "const": "subagent" + }, + "chat": { + "$ref": "#/$defs/URI", + "description": "The subagent's chat: the same chat the spawning tool call's\n{@link ToolResultSubagentContent.resource} points to." + } + }, + "required": [ + "id", + "label", + "startedAt", + "kind", + "chat" + ] + }, "SideChatSelection": { "type": "object", "description": "Immutable selected-text snapshot captured when a side chat is created.\n\nThe host records this exact text when it accepts `createChat`; later changes\nto the source chat do not alter it.", @@ -8442,6 +8595,17 @@ ], "description": "Discriminated union of all MCP server lifecycle states.\nDiscriminated by `kind` (a {@link McpServerStatus} value)." }, + "BackgroundWork": { + "oneOf": [ + { + "$ref": "#/$defs/BackgroundShellWork" + }, + { + "$ref": "#/$defs/BackgroundSubagentWork" + } + ], + "description": "Work running in the background for a chat, such as a shell or a subagent.\nClients that don't recognize a `kind` should keep the entry and may render it\nfrom the common fields." + }, "ChatOrigin": { "oneOf": [ { @@ -8949,6 +9113,12 @@ { "$ref": "#/$defs/ChatActivityChangedAction" }, + { + "$ref": "#/$defs/ChatBackgroundWorkSetAction" + }, + { + "$ref": "#/$defs/ChatBackgroundWorkRemovedAction" + }, { "$ref": "#/$defs/ChatMovableChangedAction" }, diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 177cf3998..c37aa2545 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -4270,6 +4270,13 @@ }, "description": "Catalogue of changesets the server can produce for this chat. Each entry\nadvertises a subscribable view of file changes scoped to the chat's\neffective working directories and the URI template the client expands\nbefore subscribing. See {@link Changeset} for the full shape and\n{@link /guide/changesets | Changesets} for an overview of the model.\n\nThis catalogue is intentionally absent from {@link ChatSummary}; clients\nobtain it by subscribing to the chat channel." }, + "backgroundWork": { + "type": "array", + "items": { + "$ref": "#/$defs/BackgroundWork" + }, + "description": "Work running in the background for this chat, such as shells and\nsubagents. Only active work is listed: hosts remove an entry once the work\nends. An entry may have been started by an earlier turn rather than the\n{@link ChatState.activeTurn | activeTurn}.\n\nLike {@link ChatState.changesets | changesets}, this is intentionally\nabsent from {@link ChatSummary}; clients obtain it by subscribing to the\nchat channel." + }, "turns": { "type": "array", "items": { @@ -4369,6 +4376,112 @@ "modifiedAt" ] }, + "BackgroundWorkBase": { + "type": "object", + "description": "Fields common to every {@link BackgroundWork} variant.", + "properties": { + "id": { + "type": "string", + "description": "Identifier of this entry, unique within the owning chat across all kinds.\nThe host derives it however it likes (for example from the kind plus the\nagent's own task id); consumers MUST treat it as opaque. It is the key for\nthe `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert\nconvention." + }, + "label": { + "type": "string", + "description": "Human-readable label, such as the command's purpose or the subagent's name." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the work started." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata." + } + }, + "required": [ + "id", + "label", + "startedAt" + ] + }, + "BackgroundShellWork": { + "type": "object", + "description": "A shell command continuing outside its initiating tool call. Covers shells\ntied to the agent's lifetime (attached) and shells that outlive it\n(detached). Whether a shell is attached is provider-specific and goes in its\n`_meta`.", + "properties": { + "id": { + "type": "string", + "description": "Identifier of this entry, unique within the owning chat across all kinds.\nThe host derives it however it likes (for example from the kind plus the\nagent's own task id); consumers MUST treat it as opaque. It is the key for\nthe `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert\nconvention." + }, + "label": { + "type": "string", + "description": "Human-readable label, such as the command's purpose or the subagent's name." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the work started." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata." + }, + "kind": { + "const": "shell" + }, + "command": { + "type": "string", + "description": "Command line, displayed as plain text." + }, + "terminal": { + "$ref": "#/$defs/URI", + "description": "Terminal carrying this shell's output. Hosts SHOULD set this whenever they\ncan show that output. Clients open it like\n{@link ToolResultTerminalContent.resource}; `isPty` on its\n{@link TerminalState} says whether the output is plain text." + } + }, + "required": [ + "id", + "label", + "startedAt", + "kind", + "command" + ] + }, + "BackgroundSubagentWork": { + "type": "object", + "description": "A subagent running in the background. Its own state lives in its chat.", + "properties": { + "id": { + "type": "string", + "description": "Identifier of this entry, unique within the owning chat across all kinds.\nThe host derives it however it likes (for example from the kind plus the\nagent's own task id); consumers MUST treat it as opaque. It is the key for\nthe `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert\nconvention." + }, + "label": { + "type": "string", + "description": "Human-readable label, such as the command's purpose or the subagent's name." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the work started." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata." + }, + "kind": { + "const": "subagent" + }, + "chat": { + "$ref": "#/$defs/URI", + "description": "The subagent's chat: the same chat the spawning tool call's\n{@link ToolResultSubagentContent.resource} points to." + } + }, + "required": [ + "id", + "label", + "startedAt", + "kind", + "chat" + ] + }, "SideChatSelection": { "type": "object", "description": "Immutable selected-text snapshot captured when a side chat is created.\n\nThe host records this exact text when it accepts `createChat`; later changes\nto the source chat do not alter it.", @@ -8862,6 +8975,40 @@ "type" ] }, + "ChatBackgroundWorkSetAction": { + "type": "object", + "description": "Adds or replaces a {@link BackgroundWork} entry by `id`, independently of turn\nstate.", + "properties": { + "type": { + "const": "chat/backgroundWorkSet" + }, + "work": { + "$ref": "#/$defs/BackgroundWork", + "description": "The complete entry." + } + }, + "required": [ + "type", + "work" + ] + }, + "ChatBackgroundWorkRemovedAction": { + "type": "object", + "description": "Removes finished or no-longer-tracked background work; unknown IDs are a no-op.", + "properties": { + "type": { + "const": "chat/backgroundWorkRemoved" + }, + "id": { + "type": "string", + "description": "The {@link BackgroundWorkBase.id | id} of the entry to remove." + } + }, + "required": [ + "type", + "id" + ] + }, "ChatMovableChangedAction": { "type": "object", "description": "Whether this chat is structurally eligible to be the source of `moveChat`\nchanged.\n\nThe host is authoritative and MUST also update the owning session's chat\ncatalog with `session/chatUpdated` so `ChatSummary.movable` stays in sync.\nA chat referenced by its owning session's `defaultChat` MUST always carry\n`movable: false`.", @@ -10096,6 +10243,12 @@ { "$ref": "#/$defs/ChatActivityChangedAction" }, + { + "$ref": "#/$defs/ChatBackgroundWorkSetAction" + }, + { + "$ref": "#/$defs/ChatBackgroundWorkRemovedAction" + }, { "$ref": "#/$defs/ChatMovableChangedAction" }, @@ -10656,6 +10809,17 @@ "type": "string", "description": "Why an MCP server is currently in the {@link McpServerStatus.AuthRequired}\nstate. Mirrors the three failure modes defined by the\n[MCP authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization.md)." }, + "BackgroundWork": { + "oneOf": [ + { + "$ref": "#/$defs/BackgroundShellWork" + }, + { + "$ref": "#/$defs/BackgroundSubagentWork" + } + ], + "description": "Work running in the background for a chat, such as a shell or a subagent.\nClients that don't recognize a `kind` should keep the entry and may render it\nfrom the common fields." + }, "ChatInputQuestion": { "oneOf": [ { diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 981b450f8..77c010976 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -2542,6 +2542,13 @@ }, "description": "Catalogue of changesets the server can produce for this chat. Each entry\nadvertises a subscribable view of file changes scoped to the chat's\neffective working directories and the URI template the client expands\nbefore subscribing. See {@link Changeset} for the full shape and\n{@link /guide/changesets | Changesets} for an overview of the model.\n\nThis catalogue is intentionally absent from {@link ChatSummary}; clients\nobtain it by subscribing to the chat channel." }, + "backgroundWork": { + "type": "array", + "items": { + "$ref": "#/$defs/BackgroundWork" + }, + "description": "Work running in the background for this chat, such as shells and\nsubagents. Only active work is listed: hosts remove an entry once the work\nends. An entry may have been started by an earlier turn rather than the\n{@link ChatState.activeTurn | activeTurn}.\n\nLike {@link ChatState.changesets | changesets}, this is intentionally\nabsent from {@link ChatSummary}; clients obtain it by subscribing to the\nchat channel." + }, "turns": { "type": "array", "items": { @@ -2641,6 +2648,112 @@ "modifiedAt" ] }, + "BackgroundWorkBase": { + "type": "object", + "description": "Fields common to every {@link BackgroundWork} variant.", + "properties": { + "id": { + "type": "string", + "description": "Identifier of this entry, unique within the owning chat across all kinds.\nThe host derives it however it likes (for example from the kind plus the\nagent's own task id); consumers MUST treat it as opaque. It is the key for\nthe `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert\nconvention." + }, + "label": { + "type": "string", + "description": "Human-readable label, such as the command's purpose or the subagent's name." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the work started." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata." + } + }, + "required": [ + "id", + "label", + "startedAt" + ] + }, + "BackgroundShellWork": { + "type": "object", + "description": "A shell command continuing outside its initiating tool call. Covers shells\ntied to the agent's lifetime (attached) and shells that outlive it\n(detached). Whether a shell is attached is provider-specific and goes in its\n`_meta`.", + "properties": { + "id": { + "type": "string", + "description": "Identifier of this entry, unique within the owning chat across all kinds.\nThe host derives it however it likes (for example from the kind plus the\nagent's own task id); consumers MUST treat it as opaque. It is the key for\nthe `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert\nconvention." + }, + "label": { + "type": "string", + "description": "Human-readable label, such as the command's purpose or the subagent's name." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the work started." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata." + }, + "kind": { + "const": "shell" + }, + "command": { + "type": "string", + "description": "Command line, displayed as plain text." + }, + "terminal": { + "$ref": "#/$defs/URI", + "description": "Terminal carrying this shell's output. Hosts SHOULD set this whenever they\ncan show that output. Clients open it like\n{@link ToolResultTerminalContent.resource}; `isPty` on its\n{@link TerminalState} says whether the output is plain text." + } + }, + "required": [ + "id", + "label", + "startedAt", + "kind", + "command" + ] + }, + "BackgroundSubagentWork": { + "type": "object", + "description": "A subagent running in the background. Its own state lives in its chat.", + "properties": { + "id": { + "type": "string", + "description": "Identifier of this entry, unique within the owning chat across all kinds.\nThe host derives it however it likes (for example from the kind plus the\nagent's own task id); consumers MUST treat it as opaque. It is the key for\nthe `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert\nconvention." + }, + "label": { + "type": "string", + "description": "Human-readable label, such as the command's purpose or the subagent's name." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the work started." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata." + }, + "kind": { + "const": "subagent" + }, + "chat": { + "$ref": "#/$defs/URI", + "description": "The subagent's chat: the same chat the spawning tool call's\n{@link ToolResultSubagentContent.resource} points to." + } + }, + "required": [ + "id", + "label", + "startedAt", + "kind", + "chat" + ] + }, "SideChatSelection": { "type": "object", "description": "Immutable selected-text snapshot captured when a side chat is created.\n\nThe host records this exact text when it accepts `createChat`; later changes\nto the source chat do not alter it.", @@ -7831,6 +7944,17 @@ "type": "string", "description": "Why an MCP server is currently in the {@link McpServerStatus.AuthRequired}\nstate. Mirrors the three failure modes defined by the\n[MCP authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization.md)." }, + "BackgroundWork": { + "oneOf": [ + { + "$ref": "#/$defs/BackgroundShellWork" + }, + { + "$ref": "#/$defs/BackgroundSubagentWork" + } + ], + "description": "Work running in the background for a chat, such as a shell or a subagent.\nClients that don't recognize a `kind` should keep the entry and may render it\nfrom the common fields." + }, "ChatInputQuestion": { "oneOf": [ { @@ -8410,6 +8534,12 @@ { "$ref": "#/$defs/ChatActivityChangedAction" }, + { + "$ref": "#/$defs/ChatBackgroundWorkSetAction" + }, + { + "$ref": "#/$defs/ChatBackgroundWorkRemovedAction" + }, { "$ref": "#/$defs/ChatMovableChangedAction" }, @@ -9895,6 +10025,40 @@ "type" ] }, + "ChatBackgroundWorkSetAction": { + "type": "object", + "description": "Adds or replaces a {@link BackgroundWork} entry by `id`, independently of turn\nstate.", + "properties": { + "type": { + "const": "chat/backgroundWorkSet" + }, + "work": { + "$ref": "#/$defs/BackgroundWork", + "description": "The complete entry." + } + }, + "required": [ + "type", + "work" + ] + }, + "ChatBackgroundWorkRemovedAction": { + "type": "object", + "description": "Removes finished or no-longer-tracked background work; unknown IDs are a no-op.", + "properties": { + "type": { + "const": "chat/backgroundWorkRemoved" + }, + "id": { + "type": "string", + "description": "The {@link BackgroundWorkBase.id | id} of the entry to remove." + } + }, + "required": [ + "type", + "id" + ] + }, "ChatMovableChangedAction": { "type": "object", "description": "Whether this chat is structurally eligible to be the source of `moveChat`\nchanged.\n\nThe host is authoritative and MUST also update the owning session's chat\ncatalog with `session/chatUpdated` so `ChatSummary.movable` stays in sync.\nA chat referenced by its owning session's `defaultChat` MUST always carry\n`movable: false`.", diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index c7b6a1992..f7d2cd19c 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -2720,6 +2720,13 @@ }, "description": "Catalogue of changesets the server can produce for this chat. Each entry\nadvertises a subscribable view of file changes scoped to the chat's\neffective working directories and the URI template the client expands\nbefore subscribing. See {@link Changeset} for the full shape and\n{@link /guide/changesets | Changesets} for an overview of the model.\n\nThis catalogue is intentionally absent from {@link ChatSummary}; clients\nobtain it by subscribing to the chat channel." }, + "backgroundWork": { + "type": "array", + "items": { + "$ref": "#/$defs/BackgroundWork" + }, + "description": "Work running in the background for this chat, such as shells and\nsubagents. Only active work is listed: hosts remove an entry once the work\nends. An entry may have been started by an earlier turn rather than the\n{@link ChatState.activeTurn | activeTurn}.\n\nLike {@link ChatState.changesets | changesets}, this is intentionally\nabsent from {@link ChatSummary}; clients obtain it by subscribing to the\nchat channel." + }, "turns": { "type": "array", "items": { @@ -2819,6 +2826,112 @@ "modifiedAt" ] }, + "BackgroundWorkBase": { + "type": "object", + "description": "Fields common to every {@link BackgroundWork} variant.", + "properties": { + "id": { + "type": "string", + "description": "Identifier of this entry, unique within the owning chat across all kinds.\nThe host derives it however it likes (for example from the kind plus the\nagent's own task id); consumers MUST treat it as opaque. It is the key for\nthe `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert\nconvention." + }, + "label": { + "type": "string", + "description": "Human-readable label, such as the command's purpose or the subagent's name." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the work started." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata." + } + }, + "required": [ + "id", + "label", + "startedAt" + ] + }, + "BackgroundShellWork": { + "type": "object", + "description": "A shell command continuing outside its initiating tool call. Covers shells\ntied to the agent's lifetime (attached) and shells that outlive it\n(detached). Whether a shell is attached is provider-specific and goes in its\n`_meta`.", + "properties": { + "id": { + "type": "string", + "description": "Identifier of this entry, unique within the owning chat across all kinds.\nThe host derives it however it likes (for example from the kind plus the\nagent's own task id); consumers MUST treat it as opaque. It is the key for\nthe `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert\nconvention." + }, + "label": { + "type": "string", + "description": "Human-readable label, such as the command's purpose or the subagent's name." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the work started." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata." + }, + "kind": { + "const": "shell" + }, + "command": { + "type": "string", + "description": "Command line, displayed as plain text." + }, + "terminal": { + "$ref": "#/$defs/URI", + "description": "Terminal carrying this shell's output. Hosts SHOULD set this whenever they\ncan show that output. Clients open it like\n{@link ToolResultTerminalContent.resource}; `isPty` on its\n{@link TerminalState} says whether the output is plain text." + } + }, + "required": [ + "id", + "label", + "startedAt", + "kind", + "command" + ] + }, + "BackgroundSubagentWork": { + "type": "object", + "description": "A subagent running in the background. Its own state lives in its chat.", + "properties": { + "id": { + "type": "string", + "description": "Identifier of this entry, unique within the owning chat across all kinds.\nThe host derives it however it likes (for example from the kind plus the\nagent's own task id); consumers MUST treat it as opaque. It is the key for\nthe `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert\nconvention." + }, + "label": { + "type": "string", + "description": "Human-readable label, such as the command's purpose or the subagent's name." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the work started." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata." + }, + "kind": { + "const": "subagent" + }, + "chat": { + "$ref": "#/$defs/URI", + "description": "The subagent's chat: the same chat the spawning tool call's\n{@link ToolResultSubagentContent.resource} points to." + } + }, + "required": [ + "id", + "label", + "startedAt", + "kind", + "chat" + ] + }, "SideChatSelection": { "type": "object", "description": "Immutable selected-text snapshot captured when a side chat is created.\n\nThe host records this exact text when it accepts `createChat`; later changes\nto the source chat do not alter it.", @@ -6286,6 +6399,17 @@ "type": "string", "description": "Why an MCP server is currently in the {@link McpServerStatus.AuthRequired}\nstate. Mirrors the three failure modes defined by the\n[MCP authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization.md)." }, + "BackgroundWork": { + "oneOf": [ + { + "$ref": "#/$defs/BackgroundShellWork" + }, + { + "$ref": "#/$defs/BackgroundSubagentWork" + } + ], + "description": "Work running in the background for a chat, such as a shell or a subagent.\nClients that don't recognize a `kind` should keep the entry and may render it\nfrom the common fields." + }, "ChatInputQuestion": { "oneOf": [ { diff --git a/schema/state.schema.json b/schema/state.schema.json index 74b608dcf..38f06c255 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -2453,6 +2453,13 @@ }, "description": "Catalogue of changesets the server can produce for this chat. Each entry\nadvertises a subscribable view of file changes scoped to the chat's\neffective working directories and the URI template the client expands\nbefore subscribing. See {@link Changeset} for the full shape and\n{@link /guide/changesets | Changesets} for an overview of the model.\n\nThis catalogue is intentionally absent from {@link ChatSummary}; clients\nobtain it by subscribing to the chat channel." }, + "backgroundWork": { + "type": "array", + "items": { + "$ref": "#/$defs/BackgroundWork" + }, + "description": "Work running in the background for this chat, such as shells and\nsubagents. Only active work is listed: hosts remove an entry once the work\nends. An entry may have been started by an earlier turn rather than the\n{@link ChatState.activeTurn | activeTurn}.\n\nLike {@link ChatState.changesets | changesets}, this is intentionally\nabsent from {@link ChatSummary}; clients obtain it by subscribing to the\nchat channel." + }, "turns": { "type": "array", "items": { @@ -2552,6 +2559,112 @@ "modifiedAt" ] }, + "BackgroundWorkBase": { + "type": "object", + "description": "Fields common to every {@link BackgroundWork} variant.", + "properties": { + "id": { + "type": "string", + "description": "Identifier of this entry, unique within the owning chat across all kinds.\nThe host derives it however it likes (for example from the kind plus the\nagent's own task id); consumers MUST treat it as opaque. It is the key for\nthe `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert\nconvention." + }, + "label": { + "type": "string", + "description": "Human-readable label, such as the command's purpose or the subagent's name." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the work started." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata." + } + }, + "required": [ + "id", + "label", + "startedAt" + ] + }, + "BackgroundShellWork": { + "type": "object", + "description": "A shell command continuing outside its initiating tool call. Covers shells\ntied to the agent's lifetime (attached) and shells that outlive it\n(detached). Whether a shell is attached is provider-specific and goes in its\n`_meta`.", + "properties": { + "id": { + "type": "string", + "description": "Identifier of this entry, unique within the owning chat across all kinds.\nThe host derives it however it likes (for example from the kind plus the\nagent's own task id); consumers MUST treat it as opaque. It is the key for\nthe `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert\nconvention." + }, + "label": { + "type": "string", + "description": "Human-readable label, such as the command's purpose or the subagent's name." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the work started." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata." + }, + "kind": { + "const": "shell" + }, + "command": { + "type": "string", + "description": "Command line, displayed as plain text." + }, + "terminal": { + "$ref": "#/$defs/URI", + "description": "Terminal carrying this shell's output. Hosts SHOULD set this whenever they\ncan show that output. Clients open it like\n{@link ToolResultTerminalContent.resource}; `isPty` on its\n{@link TerminalState} says whether the output is plain text." + } + }, + "required": [ + "id", + "label", + "startedAt", + "kind", + "command" + ] + }, + "BackgroundSubagentWork": { + "type": "object", + "description": "A subagent running in the background. Its own state lives in its chat.", + "properties": { + "id": { + "type": "string", + "description": "Identifier of this entry, unique within the owning chat across all kinds.\nThe host derives it however it likes (for example from the kind plus the\nagent's own task id); consumers MUST treat it as opaque. It is the key for\nthe `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert\nconvention." + }, + "label": { + "type": "string", + "description": "Human-readable label, such as the command's purpose or the subagent's name." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the work started." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata." + }, + "kind": { + "const": "subagent" + }, + "chat": { + "$ref": "#/$defs/URI", + "description": "The subagent's chat: the same chat the spawning tool call's\n{@link ToolResultSubagentContent.resource} points to." + } + }, + "required": [ + "id", + "label", + "startedAt", + "kind", + "chat" + ] + }, "SideChatSelection": { "type": "object", "description": "Immutable selected-text snapshot captured when a side chat is created.\n\nThe host records this exact text when it accepts `createChat`; later changes\nto the source chat do not alter it.", @@ -5891,6 +6004,17 @@ ], "description": "Discriminated union of all MCP server lifecycle states.\nDiscriminated by `kind` (a {@link McpServerStatus} value)." }, + "BackgroundWork": { + "oneOf": [ + { + "$ref": "#/$defs/BackgroundShellWork" + }, + { + "$ref": "#/$defs/BackgroundSubagentWork" + } + ], + "description": "Work running in the background for a chat, such as a shell or a subagent.\nClients that don't recognize a `kind` should keep the entry and may render it\nfrom the common fields." + }, "ChatOrigin": { "oneOf": [ { diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts index 0d840492a..f0df8bc41 100644 --- a/scripts/generate-csharp.ts +++ b/scripts/generate-csharp.ts @@ -642,6 +642,7 @@ const STATE_ENUMS = [ 'ToolCallContributorKind', 'ToolResultContentType', 'CustomizationType', 'CustomizationEnablementKind', 'CustomizationLoadStatus', 'TerminalClaimKind', 'TerminalLifecycleStatus', + 'BackgroundWorkKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', @@ -667,6 +668,8 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: strin { name: 'ConfigSchema' }, { name: 'PendingMessage' }, { name: 'ChatSummary', mutable: true }, + { name: 'BackgroundShellWork' }, + { name: 'BackgroundSubagentWork' }, { name: 'ChatState', mutable: true }, { name: 'ChatInputOption' }, { name: 'ChatInputTextQuestion' }, @@ -1157,6 +1160,17 @@ const SESSION_INPUT_REQUEST_UNION: UnionConfig = { unknown: true, }; +const BACKGROUND_WORK_UNION: UnionConfig = { + name: 'BackgroundWork', + discriminantField: 'kind', + doc: 'Work that keeps running after the tool call that started it returns and will resume the owning chat when it finishes.', + variants: [ + { variantName: 'Shell', innerType: 'BackgroundShellWork', wireValue: 'shell' }, + { variantName: 'Subagent', innerType: 'BackgroundSubagentWork', wireValue: 'subagent' }, + ], + unknown: true, +}; + const TERMINAL_LIFECYCLE_STATE_UNION: UnionConfig = { name: 'TerminalLifecycleState', discriminantField: 'status', @@ -1407,7 +1421,7 @@ function generateStateFile(project: Project): string { CHAT_INPUT_QUESTION_UNION, CHAT_INPUT_ANSWER_VALUE_UNION, CHAT_INPUT_ANSWER_UNION, TOOL_RESULT_CONTENT_UNION, MESSAGE_ATTACHMENT_UNION, CUSTOMIZATION_UNION, CHILD_CUSTOMIZATION_UNION, CUSTOMIZATION_LOAD_STATE_UNION, - MCP_SERVER_STATUS_UNION, TOOL_CALL_CONTRIBUTOR_UNION, SESSION_INPUT_REQUEST_UNION, + MCP_SERVER_STATUS_UNION, TOOL_CALL_CONTRIBUTOR_UNION, SESSION_INPUT_REQUEST_UNION, BACKGROUND_WORK_UNION, TERMINAL_LIFECYCLE_STATE_UNION, SESSION_ORIGIN_UNION, AUTOMATION_TRIGGER_UNION, AUTOMATION_DISABLE_CONDITION_UNION, AUTOMATION_RUN_ORIGIN_UNION, AUTOMATION_RUN_LIFECYCLE_UNION, @@ -1501,6 +1515,8 @@ const ACTION_VARIANTS: { type: string; variantName: string; tsInterface: string { type: 'chat/error', variantName: 'ChatError', tsInterface: 'ChatErrorAction' }, { type: 'chat/turnResume', variantName: 'ChatTurnResume', tsInterface: 'ChatTurnResumeAction' }, { type: 'chat/activityChanged', variantName: 'ChatActivityChanged', tsInterface: 'ChatActivityChangedAction' }, + { type: 'chat/backgroundWorkSet', variantName: 'ChatBackgroundWorkSet', tsInterface: 'ChatBackgroundWorkSetAction' }, + { type: 'chat/backgroundWorkRemoved', variantName: 'ChatBackgroundWorkRemoved', tsInterface: 'ChatBackgroundWorkRemovedAction' }, { type: 'chat/movableChanged', variantName: 'ChatMovableChanged', tsInterface: 'ChatMovableChangedAction' }, { type: 'chat/changesetsChanged', variantName: 'ChatChangesetsChanged', tsInterface: 'ChatChangesetsChangedAction' }, { type: 'chat/workingDirectorySet', variantName: 'ChatWorkingDirectorySet', tsInterface: 'ChatWorkingDirectorySetAction' }, @@ -2596,7 +2612,7 @@ function checkExhaustiveness(project: Project): void { 'SessionOrigin', 'TerminalLifecycleState', 'AutomationTrigger', 'AutomationDisableCondition', 'AutomationRunOrigin', 'AutomationRunLifecycle', - 'SessionInputRequest', 'ToolCallConfirmationState', 'ToolCallRiskAssessment', + 'SessionInputRequest', 'BackgroundWork', 'ToolCallConfirmationState', 'ToolCallRiskAssessment', 'ReconnectResult', 'AuthRequiredErrorData', 'PermissionDeniedErrorData', 'UnsupportedProtocolVersionErrorData', 'AhpError', 'AhpErrorDetailsMap', 'AhpErrorCode', 'AhpErrorCodeWithData', diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 342722319..5379a583f 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -361,7 +361,8 @@ function extractProps(iface: InterfaceDeclaration, project: Project): GoProp[] { const presenceSensitiveCollection = (iface.getName() === 'AutomationDefinitionPatch' && (tsName === 'triggers' || tsName === '_meta')) || ((iface.getName() === 'AutomationDefinition' || iface.getName() === 'AutomationDefinitionPatch') - && tsName === 'disableConditions'); + && tsName === 'disableConditions') + || (tsName === 'backgroundWork' && iface.getName() === 'ChatState'); if (optional && !alreadyPointer && (presenceSensitiveCollection || (!goType.startsWith('[]') && !goType.startsWith('map[')))) { goType = `*${goType}`; } @@ -725,6 +726,7 @@ const STATE_ENUMS = [ 'ConfirmationOptionKind', 'ToolCallContributorKind', 'ToolResultContentType', 'CustomizationType', 'CustomizationEnablementKind', 'CustomizationLoadStatus', 'TerminalClaimKind', 'TerminalLifecycleStatus', + 'BackgroundWorkKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', 'SessionOriginKind', @@ -758,6 +760,8 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'ChangesSummary' }, { name: 'ChatState' }, { name: 'ChatSummary' }, + { name: 'BackgroundShellWork' }, + { name: 'BackgroundSubagentWork' }, { name: 'SideChatSelection' }, { name: 'PendingMessage' }, { name: 'ProjectInfo' }, @@ -1128,6 +1132,17 @@ const SESSION_INPUT_REQUEST_UNION: UnionConfig = { unknown: true, }; +const BACKGROUND_WORK_UNION: UnionConfig = { + name: 'BackgroundWork', + discriminantField: 'kind', + doc: 'BackgroundWork is work that keeps running after the tool call that started it returns and will resume the owning chat when it finishes.', + variants: [ + { variantName: 'Shell', innerType: 'BackgroundShellWork', wireValue: 'shell' }, + { variantName: 'Subagent', innerType: 'BackgroundSubagentWork', wireValue: 'subagent' }, + ], + unknown: true, +}; + const SESSION_ORIGIN_UNION: UnionConfig = { name: 'SessionOrigin', discriminantField: 'kind', @@ -1510,6 +1525,7 @@ function generateStateFile(project: Project): string { lines.push(generateDiscriminatedUnion(project, TERMINAL_LIFECYCLE_STATE_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, SESSION_INPUT_REQUEST_UNION)); + lines.push(generateDiscriminatedUnion(project, BACKGROUND_WORK_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, SESSION_ORIGIN_UNION)); lines.push(''); @@ -1563,6 +1579,8 @@ const ACTION_VARIANTS: { { type: 'chat/error', variantName: 'ChatError', tsInterface: 'ChatErrorAction' }, { type: 'chat/turnResume', variantName: 'ChatTurnResume', tsInterface: 'ChatTurnResumeAction' }, { type: 'chat/activityChanged', variantName: 'ChatActivityChanged', tsInterface: 'ChatActivityChangedAction' }, + { type: 'chat/backgroundWorkSet', variantName: 'ChatBackgroundWorkSet', tsInterface: 'ChatBackgroundWorkSetAction' }, + { type: 'chat/backgroundWorkRemoved', variantName: 'ChatBackgroundWorkRemoved', tsInterface: 'ChatBackgroundWorkRemovedAction' }, { type: 'chat/movableChanged', variantName: 'ChatMovableChanged', tsInterface: 'ChatMovableChangedAction' }, { type: 'chat/changesetsChanged', variantName: 'ChatChangesetsChanged', tsInterface: 'ChatChangesetsChangedAction' }, { type: 'session/titleChanged', variantName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, @@ -2361,6 +2379,7 @@ function checkExhaustiveness(project: Project): void { 'ToolCallRiskAssessment', 'TerminalLifecycleState', 'SessionInputRequest', + 'BackgroundWork', 'ToolCallConfirmationState', 'ReconnectResult', 'SessionOrigin', diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 49292d9cd..440ee46ad 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -975,6 +975,7 @@ const STATE_ENUMS = [ 'ToolCallContributorKind', 'ToolResultContentType', 'CustomizationType', 'CustomizationEnablementKind', 'CustomizationLoadStatus', 'TerminalClaimKind', 'TerminalLifecycleStatus', + 'BackgroundWorkKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', 'SessionOriginKind', @@ -990,6 +991,7 @@ const STATE_STRUCTS = [ 'MultipleWorkingDirectoriesCapability', 'SessionModelInfo', 'ModelSelection', 'AgentSelection', 'ConfigPropertySchema', 'ConfigSchema', 'PendingMessage', 'ChatState', 'ChatSummary', 'SideChatSelection', 'SessionState', 'SessionActiveClient', + 'BackgroundShellWork', 'BackgroundSubagentWork', 'SessionChatInputRequest', 'SessionToolConfirmationRequest', 'SessionToolClientExecutionRequest', 'SessionToolAuthenticationRequest', 'SessionSummary', 'SessionChatSummary', 'ChangesSummary', 'ProjectInfo', 'SessionConfigState', 'Turn', 'ActiveTurn', 'Message', @@ -1324,6 +1326,16 @@ const SESSION_INPUT_REQUEST_UNION: UnionConfig = { unknown: true, }; +const BACKGROUND_WORK_UNION: UnionConfig = { + name: 'BackgroundWork', + discriminantField: 'kind', + variants: [ + { caseName: 'Shell', structName: 'BackgroundShellWork', discriminantValue: 'shell' }, + { caseName: 'Subagent', structName: 'BackgroundSubagentWork', discriminantValue: 'subagent' }, + ], + unknown: true, +}; + const SESSION_ORIGIN_UNION: UnionConfig = { name: 'SessionOrigin', discriminantField: 'kind', @@ -1458,6 +1470,7 @@ function generateStateFile(project: Project): string { lines.push(generateDiscriminatedUnion(project, TERMINAL_LIFECYCLE_STATE_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, SESSION_INPUT_REQUEST_UNION)); + lines.push(generateDiscriminatedUnion(project, BACKGROUND_WORK_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, SESSION_ORIGIN_UNION)); lines.push(''); @@ -1506,6 +1519,8 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'chat/error', caseName: 'ChatError', tsInterface: 'ChatErrorAction' }, { type: 'chat/turnResume', caseName: 'ChatTurnResume', tsInterface: 'ChatTurnResumeAction' }, { type: 'chat/activityChanged', caseName: 'ChatActivityChanged', tsInterface: 'ChatActivityChangedAction' }, + { type: 'chat/backgroundWorkSet', caseName: 'ChatBackgroundWorkSet', tsInterface: 'ChatBackgroundWorkSetAction' }, + { type: 'chat/backgroundWorkRemoved', caseName: 'ChatBackgroundWorkRemoved', tsInterface: 'ChatBackgroundWorkRemovedAction' }, { type: 'chat/movableChanged', caseName: 'ChatMovableChanged', tsInterface: 'ChatMovableChangedAction' }, { type: 'chat/changesetsChanged', caseName: 'ChatChangesetsChanged', tsInterface: 'ChatChangesetsChangedAction' }, { type: 'session/titleChanged', caseName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, @@ -2385,6 +2400,7 @@ function checkExhaustiveness(project: Project): void { 'ToolCallRiskAssessment', // TOOL_CALL_RISK_ASSESSMENT_UNION discriminated union 'TerminalLifecycleState', // TERMINAL_LIFECYCLE_STATE_UNION discriminated union 'SessionInputRequest', // SESSION_INPUT_REQUEST_UNION discriminated union + 'BackgroundWork', // BACKGROUND_WORK_UNION discriminated union 'ToolCallConfirmationState', // TOOL_CALL_CONFIRMATION_STATE_UNION discriminated union 'ChildCustomizationType', // TS subset alias of CustomizationType; consumers reuse CustomizationType 'CustomizationLoadState', // CUSTOMIZATION_LOAD_STATE_UNION discriminated union diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 7729bac50..1d37206c3 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -763,6 +763,7 @@ const STATE_ENUMS = [ 'ConfirmationOptionKind', 'ToolCallContributorKind', 'ToolResultContentType', 'CustomizationType', 'CustomizationEnablementKind', 'CustomizationLoadStatus', 'TerminalClaimKind', 'TerminalLifecycleStatus', + 'BackgroundWorkKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', 'SessionOriginKind', @@ -809,6 +810,8 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'PendingMessage' }, { name: 'ChatState' }, { name: 'ChatSummary' }, + { name: 'BackgroundShellWork', omitDiscriminants: true }, + { name: 'BackgroundSubagentWork', omitDiscriminants: true }, { name: 'SideChatSelection' }, { name: 'SessionState' }, { name: 'SessionActiveClient' }, @@ -1192,6 +1195,17 @@ const SESSION_INPUT_REQUEST_UNION: UnionConfig = { unknown: true, }; +const BACKGROUND_WORK_UNION: UnionConfig = { + name: 'BackgroundWork', + discriminantField: 'kind', + doc: 'Work that keeps running after the tool call that started it returns and will resume the owning chat when it finishes.', + variants: [ + { variantName: 'Shell', innerType: 'BackgroundShellWork', wireValue: 'shell' }, + { variantName: 'Subagent', innerType: 'BackgroundSubagentWork', wireValue: 'subagent' }, + ], + unknown: true, +}; + const SESSION_ORIGIN_UNION: UnionConfig = { name: 'SessionOrigin', discriminantField: 'kind', @@ -1409,6 +1423,7 @@ function generateStateFile(project: Project): string { lines.push(generateDiscriminatedUnion(project, TERMINAL_LIFECYCLE_STATE_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, SESSION_INPUT_REQUEST_UNION)); + lines.push(generateDiscriminatedUnion(project, BACKGROUND_WORK_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, SESSION_ORIGIN_UNION)); lines.push(''); @@ -1463,6 +1478,8 @@ const ACTION_VARIANTS: { { type: 'chat/error', variantName: 'ChatError', tsInterface: 'ChatErrorAction' }, { type: 'chat/turnResume', variantName: 'ChatTurnResume', tsInterface: 'ChatTurnResumeAction' }, { type: 'chat/activityChanged', variantName: 'ChatActivityChanged', tsInterface: 'ChatActivityChangedAction' }, + { type: 'chat/backgroundWorkSet', variantName: 'ChatBackgroundWorkSet', tsInterface: 'ChatBackgroundWorkSetAction' }, + { type: 'chat/backgroundWorkRemoved', variantName: 'ChatBackgroundWorkRemoved', tsInterface: 'ChatBackgroundWorkRemovedAction' }, { type: 'chat/movableChanged', variantName: 'ChatMovableChanged', tsInterface: 'ChatMovableChangedAction' }, { type: 'chat/changesetsChanged', variantName: 'ChatChangesetsChanged', tsInterface: 'ChatChangesetsChangedAction' }, { type: 'session/titleChanged', variantName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, @@ -1614,7 +1631,7 @@ impl Serialize for ChatErrorAction { function generateActionsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, ChangesSummary, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, FileEditCollection, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); + lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, BackgroundWork, ChangesSummary, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, FileEditCollection, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); // ActionType enum lines.push('// ─── ActionType ──────────────────────────────────────────────────────\n'); const actionTypeEnum = findEnum(project, 'ActionType'); @@ -2261,6 +2278,7 @@ function checkExhaustiveness(project: Project): void { 'ToolCallRiskAssessment', // TOOL_CALL_RISK_ASSESSMENT_UNION discriminated union 'TerminalLifecycleState', // TERMINAL_LIFECYCLE_STATE_UNION discriminated union 'SessionInputRequest', // SESSION_INPUT_REQUEST_UNION discriminated union + 'BackgroundWork', // BACKGROUND_WORK_UNION discriminated union 'ToolCallConfirmationState', // TOOL_CALL_CONFIRMATION_STATE_UNION discriminated union 'ReconnectResult', 'SessionOrigin', diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 237d871ae..a0bda8ae8 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -680,6 +680,7 @@ const STATE_ENUMS = [ 'ToolCallContributorKind', 'ToolResultContentType', 'CustomizationType', 'CustomizationEnablementKind', 'CustomizationLoadStatus', 'TerminalClaimKind', 'TerminalLifecycleStatus', + 'BackgroundWorkKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', 'SessionOriginKind', @@ -695,6 +696,7 @@ const STATE_STRUCTS = [ 'MultipleWorkingDirectoriesCapability', 'SessionModelInfo', 'ModelSelection', 'AgentSelection', 'ConfigPropertySchema', 'ConfigSchema', 'PendingMessage', 'ChatState', 'ChatSummary', 'SideChatSelection', 'SessionState', 'SessionActiveClient', + 'BackgroundShellWork', 'BackgroundSubagentWork', 'SessionChatInputRequest', 'SessionToolConfirmationRequest', 'SessionToolClientExecutionRequest', 'SessionToolAuthenticationRequest', 'SessionSummary', 'SessionChatSummary', 'ChangesSummary', 'ProjectInfo', 'SessionConfigState', 'Turn', 'ActiveTurn', 'Message', @@ -979,6 +981,17 @@ const SESSION_INPUT_REQUEST_UNION: UnionConfig = { ], }; +const BACKGROUND_WORK_UNION: UnionConfig = { + name: 'BackgroundWork', + discriminantField: 'kind', + // Open union: future protocol versions may add new background work kinds. + allowUnknown: true, + variants: [ + { caseName: 'shell', structName: 'BackgroundShellWork', discriminantValue: 'shell' }, + { caseName: 'subagent', structName: 'BackgroundSubagentWork', discriminantValue: 'subagent' }, + ], +}; + function generateToolResultContentUnion(): string { return `public enum ToolResultContent: Codable, Sendable { case text(ToolResultTextContent) @@ -1354,6 +1367,7 @@ function generateStateFile(project: Project): string { lines.push(generateDiscriminatedUnion(project, TERMINAL_LIFECYCLE_STATE_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, SESSION_INPUT_REQUEST_UNION)); + lines.push(generateDiscriminatedUnion(project, BACKGROUND_WORK_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, SESSION_ORIGIN_UNION)); lines.push(''); @@ -1403,6 +1417,8 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'chat/error', caseName: 'chatError', tsInterface: 'ChatErrorAction' }, { type: 'chat/turnResume', caseName: 'chatTurnResume', tsInterface: 'ChatTurnResumeAction' }, { type: 'chat/activityChanged', caseName: 'chatActivityChanged', tsInterface: 'ChatActivityChangedAction' }, + { type: 'chat/backgroundWorkSet', caseName: 'chatBackgroundWorkSet', tsInterface: 'ChatBackgroundWorkSetAction' }, + { type: 'chat/backgroundWorkRemoved', caseName: 'chatBackgroundWorkRemoved', tsInterface: 'ChatBackgroundWorkRemovedAction' }, { type: 'chat/movableChanged', caseName: 'chatMovableChanged', tsInterface: 'ChatMovableChangedAction' }, { type: 'chat/changesetsChanged', caseName: 'chatChangesetsChanged', tsInterface: 'ChatChangesetsChangedAction' }, { type: 'session/titleChanged', caseName: 'sessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, @@ -2401,6 +2417,7 @@ function checkExhaustiveness(project: Project): void { 'ToolCallRiskAssessment', // TOOL_CALL_RISK_ASSESSMENT_UNION discriminated union 'TerminalLifecycleState', // TERMINAL_LIFECYCLE_STATE_UNION discriminated union 'SessionInputRequest', // SESSION_INPUT_REQUEST_UNION discriminated union + 'BackgroundWork', // BACKGROUND_WORK_UNION discriminated union 'ToolCallConfirmationState', // TOOL_CALL_CONFIRMATION_STATE_UNION discriminated union 'AuthRequiredErrorData', // emitted by generateErrorsFile() 'PermissionDeniedErrorData', // emitted by generateErrorsFile() diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index 648c28ac6..b3a70ac6b 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -54,6 +54,8 @@ import type { ChatErrorAction, ChatTurnResumeAction, ChatActivityChangedAction, + ChatBackgroundWorkSetAction, + ChatBackgroundWorkRemovedAction, ChatMovableChangedAction, ChatChangesetsChangedAction, ChatWorkingDirectorySetAction, @@ -221,6 +223,8 @@ export type ChatAction = | ChatErrorAction | ChatTurnResumeAction | ChatActivityChangedAction + | ChatBackgroundWorkSetAction + | ChatBackgroundWorkRemovedAction | ChatMovableChangedAction | ChatChangesetsChangedAction | ChatWorkingDirectorySetAction @@ -272,6 +276,8 @@ export type ServerChatAction = | ChatTurnCompleteAction | ChatErrorAction | ChatActivityChangedAction + | ChatBackgroundWorkSetAction + | ChatBackgroundWorkRemovedAction | ChatMovableChangedAction | ChatChangesetsChangedAction | ChatUsageAction @@ -480,6 +486,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatError]: false, [ActionType.ChatTurnResume]: true, [ActionType.ChatActivityChanged]: false, + [ActionType.ChatBackgroundWorkSet]: false, + [ActionType.ChatBackgroundWorkRemoved]: false, [ActionType.ChatMovableChanged]: false, [ActionType.ChatChangesetsChanged]: false, [ActionType.ChatWorkingDirectorySet]: true, diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index c3c199306..692a1ad3f 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -9,6 +9,7 @@ import type { StringOrMarkdown, FileEditCollection, UsageInfo, URI } from '../co import type { Changeset } from '../channels-changeset/state.js'; import type { McpAuthRequirement } from '../channels-session/state.js'; import type { + BackgroundWork, Message, ResponsePart, ToolCallResult, @@ -546,6 +547,31 @@ export interface ChatActivityChangedAction { activity?: string; } +/** + * Adds or replaces a {@link BackgroundWork} entry by `id`, independently of turn + * state. + * + * @category Chat Actions + * @version 1 + */ +export interface ChatBackgroundWorkSetAction { + type: ActionType.ChatBackgroundWorkSet; + /** The complete entry. */ + work: BackgroundWork; +} + +/** + * Removes finished or no-longer-tracked background work; unknown IDs are a no-op. + * + * @category Chat Actions + * @version 1 + */ +export interface ChatBackgroundWorkRemovedAction { + type: ActionType.ChatBackgroundWorkRemoved; + /** The {@link BackgroundWorkBase.id | id} of the entry to remove. */ + id: string; +} + /** * Whether this chat is structurally eligible to be the source of `moveChat` * changed. @@ -904,6 +930,8 @@ export type ChatAction = | ChatErrorAction | ChatTurnResumeAction | ChatActivityChangedAction + | ChatBackgroundWorkSetAction + | ChatBackgroundWorkRemovedAction | ChatMovableChangedAction | ChatChangesetsChangedAction | ChatWorkingDirectorySetAction diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index 6519b6bfc..1cf16f187 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -449,6 +449,29 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st case ActionType.ChatActivityChanged: return { ...state, activity: action.activity }; + case ActionType.ChatBackgroundWorkSet: { + const list = state.backgroundWork ?? []; + const idx = list.findIndex(work => work.id === action.work.id); + const next = list.slice(); + if (idx < 0) { + next.push(action.work); + } else { + next[idx] = action.work; + } + return { ...state, backgroundWork: next }; + } + + case ActionType.ChatBackgroundWorkRemoved: { + const list = state.backgroundWork ?? []; + const idx = list.findIndex(work => work.id === action.id); + if (idx < 0) { + return state; + } + const next = list.slice(); + next.splice(idx, 1); + return { ...state, backgroundWork: next }; + } + case ActionType.ChatMovableChanged: return { ...state, movable: action.movable }; diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index d187b5e63..a3fff2314 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -106,6 +106,17 @@ export interface ChatState { * obtain it by subscribing to the chat channel. */ changesets?: Changeset[]; + /** + * Work running in the background for this chat, such as shells and + * subagents. Only active work is listed: hosts remove an entry once the work + * ends. An entry may have been started by an earlier turn rather than the + * {@link ChatState.activeTurn | activeTurn}. + * + * Like {@link ChatState.changesets | changesets}, this is intentionally + * absent from {@link ChatSummary}; clients obtain it by subscribing to the + * chat channel. + */ + backgroundWork?: BackgroundWork[]; // ── Conversation contents ────────────────────────────────────────── /** Completed turns */ @@ -193,6 +204,90 @@ export interface ChatSummary { workingDirectories?: URI[]; } +/** + * Kind of {@link BackgroundWork}. + * + * This is a general/typological union (not a lifecycle), so the discriminant is + * a `*Kind`. + * + * @category Background Work + * @nonexhaustive + */ +export const enum BackgroundWorkKind { + /** A shell command that continues after its initiating tool call returns. */ + Shell = 'shell', + /** A subagent running in the background. */ + Subagent = 'subagent', +} + +/** + * Fields common to every {@link BackgroundWork} variant. + * + * @category Background Work + */ +interface BackgroundWorkBase { + /** + * Identifier of this entry, unique within the owning chat across all kinds. + * The host derives it however it likes (for example from the kind plus the + * agent's own task id); consumers MUST treat it as opaque. It is the key for + * the `chat/backgroundWorkSet` / `chat/backgroundWorkRemoved` upsert + * convention. + */ + id: string; + /** Human-readable label, such as the command's purpose or the subagent's name. */ + label: string; + /** ISO 8601 timestamp when the work started. */ + startedAt: string; + /** Provider-specific metadata. */ + _meta?: Record; +} + +/** + * A shell command continuing outside its initiating tool call. Covers shells + * tied to the agent's lifetime (attached) and shells that outlive it + * (detached). Whether a shell is attached is provider-specific and goes in its + * `_meta`. + * + * @category Background Work + */ +export interface BackgroundShellWork extends BackgroundWorkBase { + kind: BackgroundWorkKind.Shell; + /** Command line, displayed as plain text. */ + command: string; + /** + * Terminal carrying this shell's output. Hosts SHOULD set this whenever they + * can show that output. Clients open it like + * {@link ToolResultTerminalContent.resource}; `isPty` on its + * {@link TerminalState} says whether the output is plain text. + */ + terminal?: URI; +} + +/** + * A subagent running in the background. Its own state lives in its chat. + * + * @category Background Work + */ +export interface BackgroundSubagentWork extends BackgroundWorkBase { + kind: BackgroundWorkKind.Subagent; + /** + * The subagent's chat: the same chat the spawning tool call's + * {@link ToolResultSubagentContent.resource} points to. + */ + chat: URI; +} + +/** + * Work running in the background for a chat, such as a shell or a subagent. + * Clients that don't recognize a `kind` should keep the entry and may render it + * from the common fields. + * + * @category Background Work + */ +export type BackgroundWork = + | BackgroundShellWork + | BackgroundSubagentWork; + /** * Discriminant for {@link ChatOrigin} — how a chat came into existence. * diff --git a/types/common/actions.ts b/types/common/actions.ts index 7f758e98d..14c462417 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -66,6 +66,8 @@ import type { ChatErrorAction, ChatTurnResumeAction, ChatActivityChangedAction, + ChatBackgroundWorkSetAction, + ChatBackgroundWorkRemovedAction, ChatMovableChangedAction, ChatChangesetsChangedAction, ChatWorkingDirectorySetAction, @@ -169,6 +171,8 @@ export const enum ActionType { ChatError = 'chat/error', ChatTurnResume = 'chat/turnResume', ChatActivityChanged = 'chat/activityChanged', + ChatBackgroundWorkSet = 'chat/backgroundWorkSet', + ChatBackgroundWorkRemoved = 'chat/backgroundWorkRemoved', ChatMovableChanged = 'chat/movableChanged', ChatChangesetsChanged = 'chat/changesetsChanged', ChatWorkingDirectorySet = 'chat/workingDirectorySet', @@ -331,6 +335,8 @@ export type StateAction = | ChatErrorAction | ChatTurnResumeAction | ChatActivityChangedAction + | ChatBackgroundWorkSetAction + | ChatBackgroundWorkRemovedAction | ChatMovableChangedAction | ChatChangesetsChangedAction | ChatWorkingDirectorySetAction diff --git a/types/test-cases/reducers/277-chat-backgroundworkset-adds.json b/types/test-cases/reducers/277-chat-backgroundworkset-adds.json new file mode 100644 index 000000000..04a41dfd5 --- /dev/null +++ b/types/test-cases/reducers/277-chat-backgroundworkset-adds.json @@ -0,0 +1,56 @@ +{ + "description": "setting background work appends shells and subagents in order", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "2026-09-25T00:00:00.000Z", + "turns": [] + }, + "actions": [ + { + "type": "chat/backgroundWorkSet", + "work": { + "kind": "shell", + "id": "shell-1", + "label": "Run tests", + "command": "npm test", + "startedAt": "2026-09-25T00:00:00.000Z" + } + }, + { + "type": "chat/backgroundWorkSet", + "work": { + "kind": "subagent", + "id": "subagent-1", + "label": "Review changes", + "startedAt": "2026-09-25T00:01:00.000Z", + "chat": "ahp-chat:/session/subagent-tool-1" + } + } + ], + "expected": { + "resource": "ahp-chat:/session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "2026-09-25T00:00:00.000Z", + "turns": [], + "backgroundWork": [ + { + "kind": "shell", + "id": "shell-1", + "label": "Run tests", + "command": "npm test", + "startedAt": "2026-09-25T00:00:00.000Z" + }, + { + "kind": "subagent", + "id": "subagent-1", + "label": "Review changes", + "startedAt": "2026-09-25T00:01:00.000Z", + "chat": "ahp-chat:/session/subagent-tool-1" + } + ] + } +} diff --git a/types/test-cases/reducers/278-chat-backgroundworkset-replaces.json b/types/test-cases/reducers/278-chat-backgroundworkset-replaces.json new file mode 100644 index 000000000..69fb9e26c --- /dev/null +++ b/types/test-cases/reducers/278-chat-backgroundworkset-replaces.json @@ -0,0 +1,50 @@ +{ + "description": "setting background work replaces by ID without duplication, adding its terminal", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "2026-09-25T00:00:00.000Z", + "turns": [], + "backgroundWork": [ + { + "kind": "shell", + "id": "shell-1", + "label": "Run tests", + "command": "npm test", + "startedAt": "2026-09-25T00:00:00.000Z" + } + ] + }, + "actions": [ + { + "type": "chat/backgroundWorkSet", + "work": { + "kind": "shell", + "id": "shell-1", + "label": "Run unit tests", + "command": "npm test", + "startedAt": "2026-09-25T00:00:00.000Z", + "terminal": "agenthost:/terminal/1" + } + } + ], + "expected": { + "resource": "ahp-chat:/session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "2026-09-25T00:00:00.000Z", + "turns": [], + "backgroundWork": [ + { + "kind": "shell", + "id": "shell-1", + "label": "Run unit tests", + "command": "npm test", + "startedAt": "2026-09-25T00:00:00.000Z", + "terminal": "agenthost:/terminal/1" + } + ] + } +} diff --git a/types/test-cases/reducers/279-chat-backgroundworkremoved-removes.json b/types/test-cases/reducers/279-chat-backgroundworkremoved-removes.json new file mode 100644 index 000000000..2414f3cc3 --- /dev/null +++ b/types/test-cases/reducers/279-chat-backgroundworkremoved-removes.json @@ -0,0 +1,53 @@ +{ + "description": "removing finished background work keeps other entries and chat status", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "2026-09-25T00:00:00.000Z", + "turns": [], + "backgroundWork": [ + { + "kind": "shell", + "id": "shell-1", + "label": "Run tests", + "command": "npm test", + "startedAt": "2026-09-25T00:00:00.000Z" + }, + { + "kind": "subagent", + "id": "subagent-1", + "label": "Review changes", + "startedAt": "2026-09-25T00:01:00.000Z", + "chat": "ahp-chat:/session/subagent-tool-1" + } + ] + }, + "actions": [ + { + "type": "chat/backgroundWorkRemoved", + "id": "shell-1" + }, + { + "type": "chat/backgroundWorkRemoved", + "id": "missing" + } + ], + "expected": { + "resource": "ahp-chat:/session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "2026-09-25T00:00:00.000Z", + "turns": [], + "backgroundWork": [ + { + "kind": "subagent", + "id": "subagent-1", + "label": "Review changes", + "startedAt": "2026-09-25T00:01:00.000Z", + "chat": "ahp-chat:/session/subagent-tool-1" + } + ] + } +} diff --git a/types/test-cases/reducers/280-chat-backgroundworkremoved-absent.json b/types/test-cases/reducers/280-chat-backgroundworkremoved-absent.json new file mode 100644 index 000000000..95d84cec2 --- /dev/null +++ b/types/test-cases/reducers/280-chat-backgroundworkremoved-absent.json @@ -0,0 +1,24 @@ +{ + "description": "removing background work from a chat without a list is a no-op", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "2026-09-25T00:00:00.000Z", + "turns": [] + }, + "actions": [ + { + "type": "chat/backgroundWorkRemoved", + "id": "missing" + } + ], + "expected": { + "resource": "ahp-chat:/session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "2026-09-25T00:00:00.000Z", + "turns": [] + } +} diff --git a/types/test-cases/reducers/283-chat-backgroundworkset-keeps-unknown-kind.json b/types/test-cases/reducers/283-chat-backgroundworkset-keeps-unknown-kind.json new file mode 100644 index 000000000..e34ad14d3 --- /dev/null +++ b/types/test-cases/reducers/283-chat-backgroundworkset-keeps-unknown-kind.json @@ -0,0 +1,65 @@ +{ + "description": "background work of an unknown kind is kept and replaced by id", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "2026-09-25T00:00:00.000Z", + "turns": [], + "backgroundWork": [ + { + "kind": "shell", + "id": "shell-1", + "label": "Run tests", + "command": "npm test", + "startedAt": "2026-09-25T00:00:00.000Z" + } + ] + }, + "actions": [ + { + "type": "chat/backgroundWorkSet", + "work": { + "kind": "futureKind", + "id": "future-1", + "label": "Start review", + "startedAt": "2026-09-25T00:01:00.000Z", + "target": "reviewer" + } + }, + { + "type": "chat/backgroundWorkSet", + "work": { + "kind": "futureKind", + "id": "future-1", + "label": "Review changes", + "startedAt": "2026-09-25T00:01:00.000Z", + "target": "reviewer" + } + } + ], + "expected": { + "resource": "ahp-chat:/session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "2026-09-25T00:00:00.000Z", + "turns": [], + "backgroundWork": [ + { + "kind": "shell", + "id": "shell-1", + "label": "Run tests", + "command": "npm test", + "startedAt": "2026-09-25T00:00:00.000Z" + }, + { + "kind": "futureKind", + "id": "future-1", + "label": "Review changes", + "startedAt": "2026-09-25T00:01:00.000Z", + "target": "reviewer" + } + ] + } +} diff --git a/types/test-cases/reducers/284-chat-backgroundworkremoved-removes-unknown-kind.json b/types/test-cases/reducers/284-chat-backgroundworkremoved-removes-unknown-kind.json new file mode 100644 index 000000000..323a5f683 --- /dev/null +++ b/types/test-cases/reducers/284-chat-backgroundworkremoved-removes-unknown-kind.json @@ -0,0 +1,49 @@ +{ + "description": "background work of an unknown kind is removed by id", + "reducer": "chat", + "initial": { + "resource": "ahp-chat:/session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "2026-09-25T00:00:00.000Z", + "turns": [], + "backgroundWork": [ + { + "kind": "shell", + "id": "shell-1", + "label": "Run tests", + "command": "npm test", + "startedAt": "2026-09-25T00:00:00.000Z" + }, + { + "kind": "futureKind", + "id": "future-1", + "label": "Review changes", + "startedAt": "2026-09-25T00:01:00.000Z", + "target": "reviewer" + } + ] + }, + "actions": [ + { + "type": "chat/backgroundWorkRemoved", + "id": "future-1" + } + ], + "expected": { + "resource": "ahp-chat:/session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "2026-09-25T00:00:00.000Z", + "turns": [], + "backgroundWork": [ + { + "kind": "shell", + "id": "shell-1", + "label": "Run tests", + "command": "npm test", + "startedAt": "2026-09-25T00:00:00.000Z" + } + ] + } +} diff --git a/types/version/registry.ts b/types/version/registry.ts index 54e686a9c..3709de851 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -129,6 +129,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatError]: '0.4.0', [ActionType.ChatTurnResume]: '0.9.0', [ActionType.ChatActivityChanged]: '0.5.0', + [ActionType.ChatBackgroundWorkSet]: '0.9.0', + [ActionType.ChatBackgroundWorkRemoved]: '0.9.0', [ActionType.ChatMovableChanged]: '0.9.0', [ActionType.ChatChangesetsChanged]: '0.9.0', [ActionType.ChatWorkingDirectorySet]: '0.7.0',