Skip to content

[RemoteChat] - Add the common chat surface #1485

Description

@JoshuaRowePhantom

Part of #1483

Summary

Introduce the common local/remote chat and immutable queue contracts, migrate local ownership to stable ids and revisions, and keep queue-based Copilot steering internal with no public/protocol steering API.

Scope

Implement only commit 2 of the approved design. Preserve the contracts below exactly; local and remote behavior must remain compatible. This child owns the common state, queue, and UI-facing request/options surface only; no transport behavior is added here.

Files

Files: IAgentChat, property-based Usage and AgentInformation, IAgentInputQueues, the seven queue request types and property-based snapshot/configuration/result types, owner queue adapter, stable ids/revisions in the existing queue domain, AgentChat, RunningAgentChat, RunningAgentChatLease, RunningAgentChatWithEntityInfo, AgentViewModel, AgentViewModelOptions, and InputQueueViewModel.
Tests: required-init metadata, optional defaults, named initializers, serialization, AgentChatInterfaceTests, AgentInputQueuesTests, internal Copilot/non-Copilot queue consumption tests, options/common-surface tests, and unchanged local regression suite. Migrate the UI away from concrete queue collections and index identity. No transport behavior yet.

Background and preserved design decisions

API shape convention

  • Every method and constructor in this design is audited for call-site clarity. A method with four or
    more independent arguments (including similarly typed identifiers), or any method whose positional
    arguments are easy to transpose, takes one property-based *Request or *Options value plus an
    optional CancellationToken. Small cohesive operations remain direct, for example
    ConnectAsync(AgentSessionOpenRequest request, CancellationToken ct = default).
  • Request/options and data records use object initializers. Semantically required members are
    required init; optional members have explicit defaults. Constructors are reserved for enforcing a
    scalar value invariant or receiving a small cohesive set of services.
  • Protocol DTOs are property-based records with required init payload members. Their fixed Type
    discriminator is initialized by the concrete DTO and is not caller-selectable. This changes only
    the C# construction shape: version-1 kebab-case JSON names, required/optional wire members,
    discriminators, strict unknown-member rejection, and semantics remain unchanged.
  • Existing framework/base-class overrides retain their inherited signatures. APIs consumed unchanged
    from [mxc] - Streaming process executor backed by MXC #1474-[mxc] - Execute MCP tools through MXC-constrained process executor #1477 retain the signatures owned and tested by those designs. Neither case introduces a
    new positional API in this design.

Considered/background decisions carried into this child

  1. Existing queue classes expose owner objects and use indexes for some edits.
    Resolution: retain AgentInputQueue, AgentChatQueue, AgentInputQueueManager, and
    AgentChatQueueManager as local implementation types, but expose immutable queue/item snapshots
    through IAgentInputQueues. Assign stable ids at creation/enqueue, use indexes only as placement
    hints, and route every mutation through owner-authoritative revisioned commands.

  2. Steering is an execution decision, not a separate kind of user input.
    Resolution: there is no steering member on IAgentChat and no steer protocol verb. A GUI
    enqueues through the same queue API whether a run is idle or active. The owning AgentChat
    decides from queue immediacy, mode, and current-run state whether to consume that item as
    Copilot steering or as a future turn.

Detailed design

IAgentChat - New

  • Namespace/project/file: Phantom.Workspaces.Llm;
    Phantom.Workspaces.Llm.Core/IAgentChat.cs.
  • Visibility/kind: public interface : IAsyncDisposable, IServiceProvider.
  • Responsibility/lifetime/threading: the UI-facing chat surface shared by the local engine and
    remote proxy. The implementation owns its observable collections and mutates them only on the
    foreground scheduler captured at construction. It deliberately excludes process handles,
    transports, trust profiles, and MXC objects.
public interface IAgentChat : IAsyncDisposable, IServiceProvider
{
    AgentInformation Information { get; }
    Usage Usage { get; }
    bool IsBusy { get; }
    AgentChatHistoryCollection History { get; }
    Task HistoryPopulated { get; }
    AgentChatRunningItemCollection RunningItems { get; }
    IAgentInputQueues InputQueues { get; }
    ReadOnlyObservableCollection<IRunningSubAgent> SubAgents { get; }
    ReadOnlyObservableCollection<AgentChatModal> Modals { get; }
    ISlashCommandRegistry SlashCommands { get; }
    event EventHandler? InformationChanged;
    event EventHandler? ToolsChanged;
    event EventHandler? UsageChanged;
    event EventHandler<AgentChatHistoryItem>? TurnCompleted;
    IReadOnlyList<AgentChatToolItem> GetToolSnapshot();
    Task SetToolEnabledAsync(string toolId, bool enabled, CancellationToken ct = default);
    Task RespondToModalAsync(
        string modalId, JsonElement response, CancellationToken ct = default);
    void EnqueueSystemNote(string text);
    void EnqueueHelpNote(string text);
    void EnqueueTransientDiagnostic(string text);
    void Interrupt();
}

The two state values are New public readonly record structs in
Phantom.Workspaces.Llm.Core/IAgentChat.cs, with exactly these public names, fields, types, and
semantics:

public readonly record struct Usage
{
    public Usage() { }
    public long? TotalInputTokenCount { get; init; } = null;
    public long? TotalOutputTokenCount { get; init; } = null;
    public long? TotalCacheReadTokenCount { get; init; } = null;
    public long? TotalCacheWriteTokenCount { get; init; } = null;
    public long? TotalReasoningTokenCount { get; init; } = null;
    public double? TotalSessionCostUsd { get; init; } = null;
}

public readonly record struct AgentInformation
{
    public AgentInformation() { }
    public required string AgentSessionId { get; init; }
    public required string AgentId { get; init; }
    public required string Name { get; init; }
    public required string DisplayName { get; init; }
    public required string Description { get; init; }
    public required bool AcceptsUserInput { get; init; }
    public string? CurrentModelId { get; init; } = null;
    public required AgentDefinition AgentDefinition { get; init; }
}

Usage permits null for a metric the provider did not report; counts must otherwise be nonnegative
and cost remains a nonnegative double measured in USD. AgentInformation requires non-null,
nonblank values for its first five strings and a non-null complete AgentDefinition;
CurrentModelId is null or nonblank. Required members provide compile-time construction checks;
local publishers and the protocol codec validate value invariants before accepting,
serializing, or publishing them. Local implementations build a complete replacement value before
publishing it. Proxy implementations deserialize and validate a complete replacement value before
one foreground assignment. UsageChanged and InformationChanged are raised only after that atomic
assignment, once per applied session sequence; observers never see fields from different versions.
Record equality is the intended value equality.

Both records use the existing JSON options and kebab-case protocol naming. After authorization, the
owner serializes the full definition with AgentDefinition.ToJson() and the client deserializes it
with PhantomAgentSchema.AgentDefinitionFromJson(string). Every authorized attached GUI receives
the same complete definition in AgentInformation; this is mandatory protocol state, not an
optional capability. Authorization completes before runtime lookup, snapshot construction, or
definition serialization. An unauthorized peer receives only the indistinguishable sanitized
denial and no session metadata, definition bytes, existence signal, or redacted AgentInformation.
No field-level redaction is applied after authorization.

AgentSessionIdChanged and ModelChanged are not on the common interface: those values are members
of the atomically replaced AgentInformation, and InformationChanged is their sole common event.
During implementation migration, existing scalar getters and events may temporarily forward from
Information/Usage on concrete AgentChat for source compatibility. They are not the desired
public design, must not be added to RemoteAgentChat, and are removed after local callers migrate.

Common input queues - New

  • Namespace/project/file: Phantom.Workspaces.Llm;
    Phantom.Workspaces.Llm.Core/IAgentInputQueues.cs.
  • Visibility/kind: the interfaces, snapshots, result, and status below are public. Concrete local
    and proxy implementations and protocol DTOs are internal.
  • Decision: yes, introduce one common queue aggregate. It is the only queue surface consumed by
    AgentViewModel/InputQueueViewModel, and has local and remote implementations with identical
    semantics.
public interface IAgentInputQueues
{
    AgentInputQueuesSnapshot Snapshot { get; }
    IReadOnlyList<IAgentInputQueue> Queues { get; }
    IAgentInputQueue DefaultQueue { get; }
    IAgentInputQueue ImmediateQueue { get; }
    event EventHandler? Changed;

    Task<AgentInputQueueCommandResult> CreateQueueAsync(
        CreateAgentInputQueueRequest request, CancellationToken ct = default);
    Task<AgentInputQueueCommandResult> DeleteQueueAsync(
        DeleteAgentInputQueueRequest request, CancellationToken ct = default);
    Task<AgentInputQueueCommandResult> EnqueueAsync(
        EnqueueAgentInputRequest request, CancellationToken ct = default);
    Task<AgentInputQueueCommandResult> EditAsync(
        EditAgentInputQueueItemRequest request, CancellationToken ct = default);
    Task<AgentInputQueueCommandResult> RemoveAsync(
        RemoveAgentInputQueueItemRequest request, CancellationToken ct = default);
    Task<AgentInputQueueCommandResult> MoveAsync(
        MoveAgentInputQueueItemRequest request, CancellationToken ct = default);
    Task<AgentInputQueueCommandResult> ConfigureAsync(
        ConfigureAgentInputQueueRequest request, CancellationToken ct = default);
}

public interface IAgentInputQueue
{
    AgentInputQueueSnapshot Snapshot { get; }
    event EventHandler? Changed;
}

public readonly record struct AgentInputQueuesSnapshot
{
    public required long Revision { get; init; }
    public required ImmutableArray<AgentInputQueueSnapshot> Queues { get; init; }
}

public readonly record struct AgentInputQueueSnapshot
{
    public AgentInputQueueSnapshot() { }
    public required string QueueId { get; init; }
    public required string Name { get; init; }
    public required bool IsDefault { get; init; }
    public required bool IsImmediate { get; init; }
    public required AgentInputQueueImmediacy Immediacy { get; init; }
    public required int Priority { get; init; }
    public string? CoalescingKey { get; init; } = null;
    public required long Revision { get; init; }
    public required ImmutableArray<AgentInputItemSnapshot> Items { get; init; }
}

public readonly record struct AgentInputItemSnapshot
{
    public required string ItemId { get; init; }
    public required ImmutableArray<ChatMessage> Messages { get; init; }
}

public readonly record struct AgentInputQueueConfiguration
{
    public AgentInputQueueConfiguration() { }
    public required string Name { get; init; }
    public required AgentInputQueueImmediacy Immediacy { get; init; }
    public required int Priority { get; init; }
    public string? CoalescingKey { get; init; } = null;
}

public enum AgentInputQueueCommandStatus
{
    Applied,
    Duplicate,
    Conflict,
    Rejected,
}

public readonly record struct AgentInputQueueCommandResult
{
    public AgentInputQueueCommandResult() { }
    public required Guid CommandId { get; init; }
    public required AgentInputQueueCommandStatus Status { get; init; }
    public string? QueueId { get; init; } = null;
    public string? ItemId { get; init; } = null;
    public required long Revision { get; init; }
    public string? ErrorCode { get; init; } = null;
    public AgentInputQueuesSnapshot? CurrentSnapshot { get; init; } = null;
}

public sealed record CreateAgentInputQueueRequest
{
    public required AgentInputQueueConfiguration Configuration { get; init; }
    public required Guid CommandId { get; init; }
    public required long ExpectedRevision { get; init; }
}

public sealed record DeleteAgentInputQueueRequest
{
    public required string QueueId { get; init; }
    public required Guid CommandId { get; init; }
    public required long ExpectedRevision { get; init; }
}

public sealed record EnqueueAgentInputRequest
{
    public required string TargetQueueId { get; init; }
    public required IReadOnlyList<ChatMessage> Messages { get; init; }
    public required Guid CommandId { get; init; }
    public required long ExpectedRevision { get; init; }
}

public sealed record EditAgentInputQueueItemRequest
{
    public required string QueueId { get; init; }
    public required string ItemId { get; init; }
    public required IReadOnlyList<ChatMessage> Messages { get; init; }
    public required Guid CommandId { get; init; }
    public required long ExpectedRevision { get; init; }
}

public sealed record RemoveAgentInputQueueItemRequest
{
    public required string QueueId { get; init; }
    public required string ItemId { get; init; }
    public required Guid CommandId { get; init; }
    public required long ExpectedRevision { get; init; }
}

public sealed record MoveAgentInputQueueItemRequest
{
    public required string SourceQueueId { get; init; }
    public required string ItemId { get; init; }
    public required string TargetQueueId { get; init; }
    public string? BeforeItemId { get; init; } = null;
    public required Guid CommandId { get; init; }
    public required long ExpectedRevision { get; init; }
}

public sealed record ConfigureAgentInputQueueRequest
{
    public required string QueueId { get; init; }
    public required AgentInputQueueConfiguration Configuration { get; init; }
    public required Guid CommandId { get; init; }
    public required long ExpectedRevision { get; init; }
}

await queues.MoveAsync(
    new MoveAgentInputQueueItemRequest
    {
        SourceQueueId = sourceQueueId,
        ItemId = itemId,
        TargetQueueId = targetQueueId,
        CommandId = commandId,
        ExpectedRevision = revision,
    },
    ct);

AgentInputQueue, AgentChatQueue, AgentInputQueueManager, AgentChatQueueManager, and
AgentInputItem remain the owner-side domain/implementation types; they are not serialized or
returned by the common interface. The implementation adds stable nonblank queue ids when default,
immediate, or custom queues are created and stable nonblank item ids when items are enqueued. Record
updates preserve item ids. Indexes may be calculated for display or placement, but commands identify
items only by id. BeforeItemId == null means append. MoveAsync supports reorder within one queue
and movement between queues atomically.

Snapshots are immutable point-in-time values. Their arrays and messages are deep copied through
Microsoft.Extensions.AI.AIJsonUtilities.DefaultOptions; no ObservableCollection, mutable
manager, AgentInputQueue, AgentChatQueue, AgentInputItem, ResetSession, or other live owner
reference crosses the interface or wire. IAgentInputQueue instances are stable read models for the
life of a queue and atomically replace Snapshot; Queues is a read-only projection. Affected
queues raise Changed, then the aggregate raises Changed, after all snapshots have been replaced
on the captured foreground scheduler.

The aggregate revision increases once for every applied queue transaction; each affected queue's
revision also increases once. Every command compares expectedRevision with the aggregate revision,
which gives create/delete and cross-queue movement the same deterministic conflict rule. A
cross-queue move returns both affected queue snapshots in the delta. Blank ids/names, empty message
lists, invalid enum values, negative revisions/priorities, unknown queues/items, edits of consumed
items, deletion of default/immediate or nonempty queues, and configuration that changes fixed queue
roles are rejected before mutation. Immediate, Queue, and Held retain the existing
AgentInputQueueImmediacy semantics. Hold/release is ConfigureAsync with Held or the desired
released immediacy; target selection is the explicit targetQueueId.

Commands are acknowledged and server-authoritative; there are no optimistic queue mutations.
Malformed caller arguments throw locally. Owner validation failures return Rejected with a stable
safe error code and current revision. A stale expected revision returns Conflict, performs no
mutation, and includes the complete current aggregate snapshot; the client atomically replaces its
projection, then may retry as a new command after user intent is reconciled. Exact reuse of a
commandId and canonical payload returns Duplicate with the original result and no delta; reuse
with a different payload returns Conflict. Cancellation before write performs no mutation;
cancellation after write cancels only the wait and retrying the same id is safe.

The aggregate and per-queue read models live exactly as long as their owning IAgentChat; callers
borrow them and do not dispose them. After chat disposal, mutation methods throw
ObjectDisposedException, no further Changed events are raised, and the last immutable snapshot
remains readable. The local adapter unsubscribes from both existing queue managers during chat
disposal; the proxy adapter unsubscribes from its session client before that client detaches.

The owner captures queue mutations, including consumption by a run, on its serialized runtime
scheduler and broadcasts one queue-changed delta on the existing session stream. Every delta has
the runtime epoch, global session sequence, resulting aggregate revision, affected full queue
snapshots, and removed queue ids. A fresh/reconnect session snapshot contains the full aggregate
snapshot. Replay applies deltas only in epoch/global-sequence order. Gaps, wrong epochs, or
noncontiguous queue revisions trigger normal reconnect/snapshot refresh. Multiple GUIs therefore
converge without sharing collections or trusting a client mutation.

AgentChatModal and its content hierarchy are New public immutable records in the same
namespace/file:

public sealed record AgentChatModal
{
    public required string Id { get; init; }
    public required string OwnerAgentId { get; init; }
    public required string Title { get; init; }
    public required string Body { get; init; }
    public required AgentChatModalContent Content { get; init; }
}

public abstract record AgentChatModalContent
{
    public abstract string Type { get; }
}

public sealed record FreeformModalContent : AgentChatModalContent
{
    public override string Type => "freeform";
    public string? Placeholder { get; init; } = null;
    public required bool IsRequired { get; init; }
}

public sealed record MultipleChoiceModalContent : AgentChatModalContent
{
    public override string Type => "multiple-choice";
    public required IReadOnlyList<JsonElement> Options { get; init; }
    public required bool AllowsMultiple { get; init; }
}

public sealed record ApprovalModalContent : AgentChatModalContent
{
    public override string Type => "approval";
    public required string ApproveLabel { get; init; }
    public required string RejectLabel { get; init; }
}

Initializer validation rejects blank ids/owner/title/body/type and invalid or duplicate
options/labels, and
clone option elements. The strict discriminator permits new modal content records in later protocol
versions without changing the modal envelope. All getters return the latest
foreground-applied state and never cross transport. GetToolSnapshot
returns an immutable point-in-time copy. Note-enqueue methods reject null/blank text; user input uses
InputQueues.EnqueueAsync. SetToolEnabledAsync and RespondToModalAsync validate before mutation, honor pre-write
cancellation, and serialize a command only for RemoteAgentChat. A modal response is accepted once
for an unresolved modal owned by this chat. Tool ids must exist.
Interrupt is idempotent and affects only the active turn. Events are raised after the corresponding
state mutation, in wire-sequence order for a proxy. DisposeAsync is idempotent; local disposal ends
the local chat, while proxy disposal detaches that viewer and can therefore trigger owner-side
last-viewer stop. Existing engine-only public methods remain on AgentChat and are not added to
this UI contract.

AgentChat - Existing, Modified

  • Namespace/project/file: Phantom.Workspaces.Llm;
    Phantom.Workspaces.Llm.Core/AgentChat.cs.
  • Visibility/kind: existing public sealed class, additionally implements IAgentChat.
  • Behavior: existing foreground-scheduler guarantees remain. It exposes atomic Information and
    Usage values and an owner-backed IAgentInputQueues adapter over the existing queue managers.
    That adapter is the sole public mutation path used by migrated UI code. Existing concrete scalar
    and index-based queue members are implementation-migration forwarding members only. The new
    cancellation token on the interface tool-toggle member is honored by the existing async mutation
    path. No transport branch is added to this class and it remains sealed.

The current source already makes the correct steering decision at the owner: CopilotSdkChatClient
subscribes to AgentInputQueueManager.QueueStateChanged only while a turn is live and
ForwardPendingImmediateMessages drains immediate items into CopilotSession.SendAsync with
Mode = "immediate"; ToolResultSteeringMiddleware injects immediate items at tool-result
boundaries for ordinary clients. This remains internal implementation behavior. The public
IChatSteeringTarget transport capability is narrowed to an internal split-client adapter seam;
no replacement steering method is added to IAgentChat,
RemoteAgentChat, or RemoteAgentSessionClient. Copilot-specific members
ForwardPendingImmediateMessages and SteeringMessageForwarded remain internal. During teardown,
the existing suspension gate leaves an item queued rather than losing it. For a non-Copilot client,
the same queued input is consumed at the next supported tool boundary or future turn; enqueue still
succeeds and never reports steering as unsupported.

AcquireAgentChatRequest, IRunningAgentChatTable, and running leases - Existing, Modified

  • Namespace/project/files: Phantom.Workspaces.Services;
    AcquireAgentChatRequest.cs, IRunningAgentChatTable.cs, RunningAgentChatTable.cs,
    RunningAgentChatWithEntityInfo.cs; and Phantom.Workspaces.Llm/RunningAgentChat*.cs.
  • Visibility/kind: existing public DTO/interface/sealed implementations.
public enum AgentChatAcquisitionMode { Local, AttachRemote, StartOrAttachRemote }

// Added init-only fields on AcquireAgentChatRequest:
public AgentChatAcquisitionMode AcquisitionMode { get; init; } = AgentChatAcquisitionMode.Local;
public ITransport? OwningProfileTransport { get; init; } = null;
public ReplayCursor? ReplayCursor { get; init; } = null;

public interface IRunningAgentChatTable
{
    ObservableCollection<RunningAgentChatWithEntityInfo> RunningSessions { get; }
    Task<RunningAgentChatLease> AcquireAsync(
        AcquireAgentChatRequest request, CancellationToken ct = default);
    Task<bool> TerminateAsync(
        AgentSessionId sessionId, CancellationToken ct = default);
    Task SetContinueInBackgroundAsync(
        AgentSessionId sessionId, bool continueInBackground,
        CancellationToken ct = default);
}

The three new request setters are ordinary init-only storage: they perform no I/O and clone no
transport. AcquireAsync validates their legal combinations (Local forbids
OwningProfileTransport; remote modes require it and persisted owner/generation). The request does
not own or dispose the transport.

public sealed class RunningAgentChat
{
    public AgentSessionId SessionId { get; }
    public bool IsSubAgent { get; init; }
    public Task<RunningAgentChatLease> AcquireLeaseAsync(CancellationToken ct = default);
}
public sealed class RunningAgentChatLease : IAsyncDisposable
{
    public AgentSessionId SessionId { get; }
    public IAgentChat AgentChat { get; }
    public ValueTask DisposeAsync();
}
public sealed class RunningAgentChatWithEntityInfo
{
    public AgentSessionId SessionId { get; }
    public bool IsSubAgent { get; }
    public bool IsRemote { get; }
    public bool ContinueInBackground { get; }
    public int ViewerCount { get; }
    public Task<RunningAgentChatLease> AcquireLeaseAsync(CancellationToken ct = default);
}

RunningAgentChatLease.AgentChat returns the acquired registered instance. The metadata getters on
RunningAgentChatWithEntityInfo are owner/client-authoritative snapshots and raise the existing
property-change path before RunningAgentBrainViewModel.Refresh updates a row. Lease disposal is
idempotent and decrements local/proxy viewer ownership once; it does not send an explicit terminate,
but final release can invoke the default graceful-stop transition.

Common-surface UI consumers - Existing, Modified

  • AgentViewModel:
    Phantom.Workspaces.Agent.Gui.ViewModels;
    Phantom.Workspaces.Agent.Gui/ViewModels/AgentViewModel.cs; existing public sealed class.
    Its constructor becomes AgentViewModel(AgentViewModelOptions options). AgentViewModelOptions
    has required-init IAgentChat AgentChat, string DisplayName, string Description,
    ObservableLoggerFactory LoggerFactory, and TaskScheduler ForegroundScheduler, plus
    AgentViewModel? ParentAgentViewModel { get; init; } = null. Existing foreground validation
    remains. It uses the
    common collections/events, awaits async remote tool toggles, and owns a
    ReadOnlyObservableCollection<AgentSessionModalViewModel> Modals. Modal response calls
    Task RespondToModalAsync(string modalId, JsonElement response, CancellationToken ct = default),
    which delegates to IAgentChat, preserves the modal until a dismiss event, and maps cancellation
    or safe remote errors without optimistic removal.
    Input is gated only while this editor has an unresolved modal; descendant modals affect only root
    notification aggregation. Its existing public AgentChat getter changes to
    public IAgentChat AgentChat { get; }. Disposal unsubscribes before disposing its chat lease/proxy.
  • InputQueueViewModel:
    Phantom.Workspaces.Agent.Gui.ViewModels; existing public sealed class. Its constructor and
    fields consume IAgentChat.InputQueues, IAgentInputQueue, and immutable snapshots rather than
    concrete AgentChat, AgentChatQueue, or AgentInputQueueManager. Submit, create, edit, remove,
    move, hold/release, and target selection await the aggregate commands. Controls stay unchanged
    until the acknowledged owner delta/result is applied; conflict replaces the projection from the
    returned snapshot and prompts/retries only after reconciling user intent. Changed is marshalled
    to the existing foreground scheduler, and disposal unsubscribes without disposing the chat-owned
    aggregate.

The added/changed public signatures are:

public sealed record AgentViewModelOptions
{
    public required IAgentChat AgentChat { get; init; }
    public required string DisplayName { get; init; }
    public required string Description { get; init; }
    public required ObservableLoggerFactory LoggerFactory { get; init; }
    public required TaskScheduler ForegroundScheduler { get; init; }
    public AgentViewModel? ParentAgentViewModel { get; init; } = null;
}

public AgentViewModel(AgentViewModelOptions options);
public IAgentChat AgentChat { get; }
public ReadOnlyObservableCollection<AgentSessionModalViewModel> Modals { get; }
public Task RespondToModalAsync(
    string modalId, JsonElement response, CancellationToken ct = default);

public bool IsRemote { get; }
public string? RemoteProfileDisplayName { get; }
public bool HasModalsNeedingInput { get; }
public void SetReady(AgentViewModel agentViewModel, ObservableLoggerFactory factory);

public override Task<bool> Handle(
    MainWindowViewModel mainWindowViewModel,
    Shortcut shortcut,
    SubscribedEntityViewModel entityViewModel);
public Task<AgentSessionWorkspaceTabViewModel?> TryCreateAgentSessionTabForRestoreAsync(
    CreateAgentSessionTabForRestoreRequest request, CancellationToken ct = default);
public override Task<WorkspaceTabViewModel?> TryCreateTabForRestoreAsync(
    MainWindowViewModel mainWindowViewModel,
    SubscribedEntityViewModel entityViewModel,
    string? tabId,
    string? title,
    string? dockRegion);
public Task<AgentSessionWorkspaceTabViewModel> CreateAgentSessionTabAsync(
    CreateAgentSessionTabRequest request, CancellationToken ct = default);
public AgentViewModel ComposeSessionAgentViewModel(ComposeSessionAgentViewModelOptions options);

public sealed record CreateAgentSessionTabForRestoreRequest
{
    public required MainWindowViewModel MainWindowViewModel { get; init; }
    public required SubscribedEntityViewModel AgentSessionEntity { get; init; }
    public string? TabId { get; init; } = null;
    public string? Title { get; init; } = null;
    public string? DockRegion { get; init; } = null;
}

public sealed record CreateAgentSessionTabRequest
{
    public required MainWindowViewModel MainWindowViewModel { get; init; }
    public required SubscribedEntityViewModel AgentSessionEntity { get; init; }
    public required IAgentChat AgentChat { get; init; }
}

public sealed record ComposeSessionAgentViewModelOptions
{
    public required MainWindowViewModel MainWindowViewModel { get; init; }
    public required ObservableLoggerFactory LoggerFactory { get; init; }
    public required IAgentChat AgentChat { get; init; }
    public required SubscribedEntityViewModel AgentSessionEntity { get; init; }
    public required AgentSessionWorkspaceTabViewModel Tab { get; init; }
    public required TaskScheduler ForegroundScheduler { get; init; }
}

var viewModel = new AgentViewModel(
    new AgentViewModelOptions
    {
        AgentChat = agentChat,
        DisplayName = displayName,
        Description = description,
        LoggerFactory = loggerFactory,
        ForegroundScheduler = foregroundScheduler,
    });

SlashCommandContext is Existing, Modified in Phantom.Workspaces.Llm.SlashCommands,
Phantom.Workspaces.Llm.Core/SlashCommands/SlashCommandContext.cs: its
public required IAgentChat AgentChat { get; init; } replaces the concrete type. The init setter
rejects null. Handlers needing engine-only APIs are not registered by RemoteAgentChat.SlashCommands;
common handlers use only IAgentChat. This prevents a hidden concrete cast in the open path.

Tests

AgentChatInterfaceTests (Phantom.Workspaces.Llm.Core.Tests)

  • Information_LocalChat_ReturnsAtomicAgentInformation.
  • Usage_LocalChat_ReturnsAtomicUsage.
  • InputQueues_LocalChat_ReturnsCommonQueueAggregate.
  • GetToolSnapshot_MutationAfterRead_DoesNotChangeReturnedSnapshot.
  • SetToolEnabledAsync_KnownTool_ChangesStateThenRaisesToolsChanged.
  • SetToolEnabledAsync_UnknownTool_ThrowsArgumentException.
  • SetToolEnabledAsync_Cancelled_DoesNotMutateOrRaiseEvent.
  • RespondToModalAsync_CurrentModal_AcceptsExactlyOnce.
  • RespondToModalAsync_UnknownModal_ThrowsArgumentException.
  • EnqueueSystemNote_ValidText_AppendsSystemNote.
  • EnqueueHelpNote_ValidText_AppendsHelpNote.
  • EnqueueTransientDiagnostic_ValidText_AppendsNonPersistedDiagnostic.
  • Interrupt_ActiveTurn_CancelsTurnWithoutDisposingChat.
  • Interrupt_NoActiveTurn_IsIdempotent.
  • Usage_EqualValues_CompareEqual.
  • Usage_DefaultInitialization_AllOptionalMetricsAreNull.
  • Usage_NamedInitializer_PreservesExactMetricTypes.
  • Usage_RoundTrip_PreservesNullableCountsAndDoubleUsd.
  • UsagePublisher_NegativeMetric_RejectsBeforePublication.
  • UsageChanged_CompleteReplacement_StateVisibleBeforeSingleEvent.
  • AgentInformation_EqualValues_CompareEqual.
  • AgentInformation_RequiredInitProperties_AreMarkedRequired.
  • AgentInformation_NamedInitializer_PreservesAllFields.
  • AgentInformationPublisher_InvalidRequiredString_RejectsBeforePublication.
  • AgentInformationPublisher_InvalidOptionalModel_RejectsBeforePublication.
  • AgentInformation_AuthorizedPeer_RoundTripsCompleteDefinition.
  • AgentInformationPublisher_NullDefinition_RejectsBeforePublication.
  • SessionSnapshot_TwoAuthorizedViewers_ReceiveEquivalentFullDefinition.
  • OpenAsync_UnauthorizedPeer_SerializesNoSessionMetadata.
  • InformationChanged_SessionAndModelChange_StateVisibleBeforeSingleEvent.
  • TurnCompleted_TurnPersists_EventRaisedAfterHistoryMutation.
  • DisposeAsync_RepeatedCall_DisposesOnce.
  • GetService_KnownService_ReturnsExistingService.

AgentInputQueuesTests (Phantom.Workspaces.Llm.Core.Tests)

  • Snapshot_LocalQueue_ReturnsDeepImmutableCopy.
  • QueueSnapshotPublisher_InvalidRevisionOrDuplicateIds_RejectsBeforePublication.
  • QueueSnapshotPublisher_InvalidIdentityRoleOrRevision_RejectsBeforePublication.
  • QueueSnapshotPublisher_InvalidItemIdentityOrMessages_RejectsBeforePublication.
  • QueueCommandValidator_InvalidNameImmediacyOrPriority_RejectsBeforeMutation.
  • AgentInputQueueCommandResult_RoundTrip_PreservesStatusRevisionAndSnapshot.
  • QueueRequestTypes_RequiredInitProperties_AreMarkedRequired.
  • QueueRequestTypes_OptionalProperties_UseDocumentedDefaults.
  • QueueRequestTypes_NamedInitializers_MapToExactCommandSerialization.
  • Queues_LocalAndProxy_ExposeEquivalentReadModels.
  • DefaultQueue_LocalAndProxy_ReturnSameStableQueueId.
  • ImmediateQueue_LocalAndProxy_ReturnSameStableQueueId.
  • CreateQueueAsync_ValidConfiguration_AssignsStableQueueIdAndRevision.
  • DeleteQueueAsync_CustomQueue_RemovesQueueOnce.
  • DeleteQueueAsync_DefaultOrImmediateQueue_ReturnsRejected.
  • EnqueueAsync_DefaultImmediateHeldAndCustomTargets_AssignsStableItemIds.
  • EnqueueAsync_CancelledBeforeMutation_DoesNotChangeRevision.
  • EditAsync_ExistingItem_PreservesItemIdAndAdvancesRevision.
  • RemoveAsync_ExistingItem_RemovesByIdNotIndex.
  • MoveAsync_BeforeItem_ReordersByStableIds.
  • MoveAsync_DifferentTarget_MovesAtomicallyAcrossQueues.
  • ConfigureAsync_ImmediateQueueInvalidRoleChange_ReturnsRejected.
  • ConfigureAsync_HoldAndRelease_ChangesImmediacy.
  • Command_StaleExpectedRevision_ReturnsConflictAndAuthoritativeSnapshot.
  • Command_DuplicateIdSamePayload_ReturnsOriginalResultWithoutSecondMutation.
  • Command_DuplicateIdDifferentPayload_ReturnsConflict.
  • Changed_AppliedCommand_ReplacesSnapshotBeforeEvent.
  • QueueChanged_AppliedCommand_ReplacesQueueSnapshotBeforeEvent.
  • QueueConsumption_ActiveRun_AdvancesRevisionAndRaisesChanged.
  • EnqueueAsync_ActiveCopilotRun_ConsumesAsInternalSteering.
  • EnqueueAsync_ActiveNonCopilotRun_RemainsQueuedUntilSupportedBoundaryOrFutureTurn.

CurrentSessionContextTests and AgentServicesTests

  • CurrentSessionContext_ValidOwnerGenerationEpoch_PreservesOwningHostIdentity.
  • CurrentSessionContext_BlankOwner_RejectsInitialization.
  • CurrentSessionContext_NegativeGeneration_RejectsInitialization.
  • CurrentSessionContext_AttachmentPeer_DoesNotReplaceHostIdentity.
  • AgentServices_AgentExecutionTrustContextSetter_WithExpressionPreservesOtherServices.
  • AgentServices_RemoteRuntimeIntentSetter_WithExpressionPreservesOtherServices.
  • AgentServices_GetService_NewObjectTypedSeams_DoesNotExposeConcreteTypes.
  • AcquireAgentChatRequest_RemoteInitProperties_PreserveModeTransportAndCursor.
  • AcquireAgentChatRequest_Defaults_SelectLocalModeWithoutTransportOrCursor.
  • AcquireAgentChatRequest_InvalidModeCombination_AcquireRejectsRequest.
  • AgentViewModel_AgentChatProperty_LocalAndRemote_ReturnsIAgentChat.
  • RunningAgentChatLease_AgentChatProperty_LocalAndRemote_ReturnsIAgentChat.
  • RunningAgentChatWithEntityInfo_RetentionMetadataChange_RaisesAuthoritativeUpdate.

UI public API tests

AgentViewModelTests:

  • Constructor_RemoteChat_UsesCommonSurfaceWithoutConcreteCast.
  • AgentViewModelOptions_NamedInitializer_PreservesRequiredValuesAndParentDefault.
  • Constructor_WrongForegroundContext_Throws.
  • RespondToModalAsync_CurrentModal_SendsResponseAndKeepsInputGatedUntilDismissed.
  • RespondToModalAsync_UnknownModal_ThrowsArgumentException.
  • RespondToModalAsync_Cancelled_DoesNotDismissModal.
  • ModalEvent_DescendantModal_UpdatesRootAggregateOnly.
  • DisposeAsync_RemoteChat_UnsubscribesBeforeDetaching.
  • InterruptCommand_RemoteChat_InvokesCommonInterrupt.
  • ConfigureSlashCommands_RemoteChat_RegistersOnlyCommonHandlers.

InputQueueViewModelTests:

  • CommandPending_RemoteQueue_DoesNotMutateProjectionOptimistically.
  • CommandConflict_StaleRevision_RefreshesFromAuthoritativeSnapshot.
  • CommandApplied_AuthoritativeDeltaAppliedBeforeTaskCompletes.

SlashCommandContextTests:

  • AgentChatSetter_LocalOrRemote_PreservesCommonChat.
  • AgentChatSetter_Null_RejectsInitialization.

Implementation commit mapping

Commit 2 - Add the common chat surface

Files: IAgentChat, property-based Usage and AgentInformation, IAgentInputQueues,
the seven queue request types and property-based snapshot/configuration/result types, owner queue
adapter, stable ids/revisions in the existing queue domain, AgentChat,
RunningAgentChat, RunningAgentChatLease, RunningAgentChatWithEntityInfo, AgentViewModel, and
AgentViewModelOptions, and InputQueueViewModel.
Tests: required-init metadata, optional defaults, named initializers, serialization,
AgentChatInterfaceTests, AgentInputQueuesTests, internal Copilot/non-Copilot queue consumption
tests, options/common-surface tests, and unchanged local regression suite. Migrate
the UI away from concrete queue collections and index identity. No transport behavior yet.
Dependencies: none.

Dependencies

None.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingverified-locallyImplementation has been verified locally

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions