Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -1724,6 +1728,25 @@ public sealed record ChatActivityChangedAction
public string? Activity { get; init; }
}

/// <summary>Adds or replaces a {@link BackgroundWork} entry by `id`, independently of turn
/// state.</summary>
public sealed record ChatBackgroundWorkSetAction
{
public ActionType Type { get; init; }

/// <summary>The complete entry.</summary>
public required BackgroundWork Work { get; init; }
}

/// <summary>Removes finished or no-longer-tracked background work; unknown IDs are a no-op.</summary>
public sealed record ChatBackgroundWorkRemovedAction
{
public ActionType Type { get; init; }

/// <summary>The {@link BackgroundWorkBase.id | id} of the entry to remove.</summary>
public required string Id { get; init; }
}

/// <summary>Whether this chat is structurally eligible to be the source of `moveChat`
/// changed.
///
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand All @@ -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))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,21 @@ public enum TerminalLifecycleStatus
Exited,
}

/// <summary>Kind of {@link BackgroundWork}.
///
/// This is a general/typological union (not a lifecycle), so the discriminant is
/// a `*Kind`.</summary>
[JsonConverter(typeof(WireEnumConverter<BackgroundWorkKind>))]
public enum BackgroundWorkKind
{
/// <summary>A shell command that continues after its initiating tool call returns.</summary>
[WireValue("shell")]
Shell,
/// <summary>A subagent running in the background.</summary>
[WireValue("subagent")]
Subagent,
}

/// <summary>Discriminant for the {@link McpServerState} union.</summary>
[JsonConverter(typeof(WireEnumConverter<McpServerStatus>))]
public enum McpServerStatus
Expand Down Expand Up @@ -1174,6 +1189,71 @@ public sealed class ChatSummary
public List<string>? WorkingDirectories { get; set; }
}

/// <summary>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`.</summary>
public sealed record BackgroundShellWork
{
/// <summary>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.</summary>
public required string Id { get; init; }

/// <summary>Human-readable label, such as the command's purpose or the subagent's name.</summary>
public required string Label { get; init; }

/// <summary>ISO 8601 timestamp when the work started.</summary>
public required string StartedAt { get; init; }

/// <summary>Provider-specific metadata.</summary>
[JsonPropertyName("_meta")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, JsonElement>? Meta { get; init; }

public BackgroundWorkKind Kind { get; init; }

/// <summary>Command line, displayed as plain text.</summary>
public required string Command { get; init; }

/// <summary>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.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Terminal { get; init; }
}

/// <summary>A subagent running in the background. Its own state lives in its chat.</summary>
public sealed record BackgroundSubagentWork
{
/// <summary>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.</summary>
public required string Id { get; init; }

/// <summary>Human-readable label, such as the command's purpose or the subagent's name.</summary>
public required string Label { get; init; }

/// <summary>ISO 8601 timestamp when the work started.</summary>
public required string StartedAt { get; init; }

/// <summary>Provider-specific metadata.</summary>
[JsonPropertyName("_meta")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, JsonElement>? Meta { get; init; }

public BackgroundWorkKind Kind { get; init; }

/// <summary>The subagent's chat: the same chat the spawning tool call's
/// {@link ToolResultSubagentContent.resource} points to.</summary>
public required string Chat { get; init; }
}

/// <summary>Full state for a single chat, loaded when a client subscribes to the chat's
/// URI.
///
Expand Down Expand Up @@ -1255,6 +1335,17 @@ public sealed class ChatState
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<Changeset>? Changesets { get; set; }

/// <summary>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.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<BackgroundWork>? BackgroundWork { get; set; }

/// <summary>Completed turns</summary>
public required List<Turn> Turns { get; set; }

Expand Down Expand Up @@ -6193,6 +6284,33 @@ public SessionInputRequestConverter()
}
}

/// <summary>Work that keeps running after the tool call that started it returns and will resume the owning chat when it finishes.</summary>
[JsonConverter(typeof(BackgroundWorkConverter))]
public sealed class BackgroundWork : AhpUnion
{
/// <summary>Creates an empty BackgroundWork (no active variant).</summary>
public BackgroundWork() { }

/// <summary>Creates a BackgroundWork wrapping the given variant value.</summary>
public BackgroundWork(object? value) : base(value) { }
}

/// <summary>System.Text.Json converter for the BackgroundWork discriminated union.</summary>
internal sealed class BackgroundWorkConverter : UnionConverter<BackgroundWork>
{
public BackgroundWorkConverter()
: base(
discriminator: "kind",
variants: new Dictionary<string, Type>
{
["shell"] = typeof(BackgroundShellWork),
["subagent"] = typeof(BackgroundSubagentWork),
},
allowUnknown: true)
{
}
}

/// <summary>TerminalLifecycleState is the current lifecycle of a terminal process.</summary>
[JsonConverter(typeof(TerminalLifecycleStateConverter))]
public sealed class TerminalLifecycleState : AhpUnion
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions clients/dotnet/src/AgentHostProtocol/Reducers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ private static SessionStatus WithInputNeededStatus(SessionStatus status, List<Se
// the shared base). Unknown future kinds are preserved as a raw JsonElement
// by the union converter — read the id structurally so forward-compat entries
// still upsert/remove correctly.
private static string BackgroundWorkId(BackgroundWork work) => 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,
Expand Down Expand Up @@ -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<BackgroundWork>();
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;
Expand Down
48 changes: 48 additions & 0 deletions clients/go/ahp/reducers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading