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',