diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index 394577de9..c9939544f 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -784,19 +784,20 @@ public sealed record SessionActiveClientRemovedAction public required string ClientId { get; init; } } -/// A working directory was added to the session's +/// A working directory was added or updated in the session's /// {@link SessionState.workingDirectories} set. /// -/// Membership semantics keyed by the directory URI: the reducer appends -/// `directory` when the set does not already contain it (creating the set if -/// absent) and is a no-op when it is already present. Only valid when the agent -/// advertises {@link AgentCapabilities.multipleWorkingDirectories}. +/// Upsert keyed by URI. A rich record replaces the existing entry in place; +/// a URI-only duplicate preserves known metadata. Only valid when the agent +/// advertises {@link AgentCapabilities.multipleWorkingDirectories}. Clients +/// MUST omit `repo` and use only local or absent `origin`; the host validates +/// and enriches the entry before accepting it. public sealed record SessionWorkingDirectorySetAction { public ActionType Type { get; init; } = ActionType.SessionWorkingDirectorySet; /// The working directory to grant the session's agent tool access to. - public required string Directory { get; init; } + public required WorkingDirectoryEntry Directory { get; init; } } /// A working directory was removed from the session's @@ -829,13 +830,16 @@ public sealed record SessionWorkingDirectoryRemovedAction /// for example, `[A, B, C]` with `B → C` becomes `[A, C]`. When it occurs /// before the target, it retains its earlier position and the target is removed; /// `[A, B, C]` with `C → A` becomes `[A, B]`. +/// A rich replacement supplies the complete entry; a URI-only replacement +/// preserves metadata already known for that URI. /// /// Only valid when the agent advertises /// {@link AgentCapabilities.multipleWorkingDirectories}. Replacing index `0` /// additionally requires /// {@link MultipleWorkingDirectoriesCapability.primaryReplacement}; clients /// MUST NOT target an immutable primary. The host MUST validate and apply its -/// backend side effect before broadcasting an accepted action, or reject it. +/// backend side effect before broadcasting an accepted action, or reject it. +/// Clients MUST omit `repo` and use only local or absent `origin`. public sealed record SessionWorkingDirectoryReplacedAction { public ActionType Type { get; init; } = ActionType.SessionWorkingDirectoryReplaced; @@ -843,8 +847,8 @@ public sealed record SessionWorkingDirectoryReplacedAction /// URI of the existing entry to replace. public required string Directory { get; init; } - /// URI to place in the replaced entry's position. - public required string Replacement { get; init; } + /// Complete entry to place in the replaced entry's position. + public required WorkingDirectoryEntry Replacement { get; init; } } /// A session-level input request was added or updated. @@ -1869,8 +1873,9 @@ public sealed record CanvasStateChangedAction /// Membership semantics keyed by the directory URI: the reducer appends /// `directory` when the chat's subset does not already contain it (creating the /// subset if absent) and is a no-op when it is already present. `directory` MUST -/// be one of the owning session's {@link SessionState.workingDirectories}; a host -/// MUST reject a directory that is not. Only valid when the agent advertises +/// match a URI string or record's `uri` in the owning session's +/// {@link SessionState.workingDirectories}; a host MUST reject a directory that +/// does not. Only valid when the agent advertises /// {@link AgentCapabilities.multipleWorkingDirectories}. public sealed record ChatWorkingDirectorySetAction { diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs index 13801df80..71b085d37 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs @@ -331,6 +331,21 @@ public override void Write(Utf8JsonWriter writer, AuthenticateTargetKind value, // ─── Command Payloads ───────────────────────────────────────────────── +/// Requested repository source, not a resolved working directory. +public sealed record RepositorySource +{ + /// Credential-free repository source URI. + public required string Source { get; init; } + + /// Requested branch, tag, or commit. Omit to use the host's default revision. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Revision { get; init; } + + /// Repository-relative selected folder; omit for the root. Hosts reject empty, absolute, or escaping paths. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Subdirectory { get; init; } +} + /// Opens a TCP connection from the host to a destination on the host's network. /// /// The client chooses the channel URI. This command does not subscribe: after it @@ -495,6 +510,10 @@ public sealed record InitializeResult [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? DefaultDirectory { get; init; } + /// Host repository preparation support; absent when unsupported. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public RepositoryPreparationCapability? RepositoryPreparation { get; init; } + /// Characters that, when typed in a {@link Message} input, SHOULD cause /// the client to issue a `completions` request with /// {@link CompletionItemKind.UserMessage}. Typically includes characters like @@ -576,6 +595,11 @@ public sealed record Implementation /// are reserved for future per-capability options. public sealed record ClientCapabilities { + /// Client accepts rich {@link WorkingDirectory} records as well as URI strings. + /// Hosts project records to URIs when absent and retain this choice on reconnect. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WorkingDirectoryInfoCapability? WorkingDirectoryInfo { get; init; } + /// Client can render /// [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) — i.e. /// it can host the View sandbox, run the `ui/*` protocol against it, @@ -591,6 +615,24 @@ public sealed record ClientCapabilities public Dictionary? McpApps { get; init; } } +/// Host support for repository preparation at session creation. Presence +/// alone supports one repository at its default revision. +public sealed record RepositoryPreparationCapability +{ + /// When true, clients may supply {@link RepositorySource.revision}. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Revision { get; init; } + + /// When true, clients may supply more than one repository. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? MultipleRepositories { get; init; } +} + +/// Presence capability for {@link ClientCapabilities.workingDirectoryInfo}. +public sealed record WorkingDirectoryInfoCapability +{ +} + /// Automation features supported by this host authority. /// /// The presence of this object advertises the baseline `ahp-automations://` @@ -947,6 +989,11 @@ public sealed record CreateSessionParams [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? WorkingDirectories { get; init; } + /// Repositories to prepare instead of an explicit `workingDirectories` list. + /// An empty list is equivalent to omission. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Repositories { get; init; } + /// Agent-specific configuration values collected via `resolveSessionConfig`. /// Keys and values correspond to the schema returned by the server. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -1070,9 +1117,9 @@ public sealed record CreateChatParams [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ChatSource? Source { get; init; } - /// Initial working-directory subset for this chat. Every entry MUST be - /// present in the owning session's `workingDirectories`; the server MUST - /// reject any entry that is not. When absent, the chat inherits the full + /// Initial working-directory URI subset for this chat. Every URI MUST match + /// a URI string or record's `uri` in the owning session's `workingDirectories`; + /// the server MUST reject any entry that does not. When absent, the chat inherits the full /// session set. Forked chats (those whose `source.kind` is `"fork"`) inherit /// the source chat's `workingDirectories`; this field is ignored for forks. /// @@ -2002,6 +2049,10 @@ public sealed record ResolveSessionConfigParams [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? WorkingDirectory { get; init; } + /// Repositories used as configuration context; an empty list is equivalent to omission. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Repositories { get; init; } + /// Current user-filled configuration values [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Dictionary? Config { get; init; } @@ -2040,6 +2091,10 @@ public sealed record SessionConfigCompletionsParams [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? WorkingDirectory { get; init; } + /// Repositories used as configuration context; an empty list is equivalent to omission. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Repositories { get; init; } + /// Current user-filled configuration values (provides context for the query) [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Dictionary? Config { get; init; } diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs index 581549391..25588f646 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs @@ -266,6 +266,7 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(ListAutomationTriggerDefinitionsResult))] [JsonSerializable(typeof(ListSessionsParams))] [JsonSerializable(typeof(ListSessionsResult))] +[JsonSerializable(typeof(LocalWorkingDirectoryOrigin))] [JsonSerializable(typeof(MarkdownResponsePart))] [JsonSerializable(typeof(McpAuthRequiredReason))] [JsonSerializable(typeof(McpAuthRequirement))] @@ -316,6 +317,9 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(ReconnectResult))] [JsonSerializable(typeof(ReconnectResultType))] [JsonSerializable(typeof(ReconnectSnapshotResult))] +[JsonSerializable(typeof(RepositoryPreparationCapability))] +[JsonSerializable(typeof(RepositorySource))] +[JsonSerializable(typeof(RepoWorkingDirectoryOrigin))] [JsonSerializable(typeof(ResolveSessionConfigParams))] [JsonSerializable(typeof(ResolveSessionConfigResult))] [JsonSerializable(typeof(ResourceChange))] @@ -514,6 +518,12 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(UnsubscribeParams))] [JsonSerializable(typeof(UnsupportedProtocolVersionErrorData))] [JsonSerializable(typeof(UsageInfo))] +[JsonSerializable(typeof(WorkingDirectory))] +[JsonSerializable(typeof(WorkingDirectoryEntry))] +[JsonSerializable(typeof(WorkingDirectoryInfoCapability))] +[JsonSerializable(typeof(WorkingDirectoryOrigin))] +[JsonSerializable(typeof(WorkingDirectoryOriginKind))] +[JsonSerializable(typeof(WorktreeWorkingDirectoryOrigin))] [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.Never, diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs index 0b4da6a7d..85ddab128 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs @@ -443,9 +443,11 @@ public sealed record PartialSessionSummary /// (the first entry is a protected, replaceable primary slot). Individual chats /// MAY restrict to a subset via /// {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a - /// chat that sets none operates against this full set. + /// chat that sets none operates against this full set. + /// Entries are uniquely keyed by URI. Rich records require + /// {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List? WorkingDirectories { get; init; } + public List? WorkingDirectories { get; init; } /// Lightweight summary of this session's inline annotations channel /// (`ahp-annotations:/<uuid>`). Surfaced so badge UI can render diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index 641370767..2d19212c9 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -176,6 +176,59 @@ public override void Write(Utf8JsonWriter writer, SessionOriginKind value, JsonS => writer.WriteStringValue(value.Value); } +/// How a working directory was prepared. +[JsonConverter(typeof(WorkingDirectoryOriginKindConverter))] +public readonly struct WorkingDirectoryOriginKind : IEquatable +{ + private readonly string? _value; + + /// Wraps a raw wire value — including one this build does not recognize. + /// The raw wire string. + public WorkingDirectoryOriginKind(string value) + { + _value = value; + } + + /// The raw wire value. + public string Value => _value ?? string.Empty; + + public static readonly WorkingDirectoryOriginKind Local = new WorkingDirectoryOriginKind("local"); + + public static readonly WorkingDirectoryOriginKind Repo = new WorkingDirectoryOriginKind("repo"); + + public static readonly WorkingDirectoryOriginKind Worktree = new WorkingDirectoryOriginKind("worktree"); + + /// + public bool Equals(WorkingDirectoryOriginKind other) => string.Equals(Value, other.Value, StringComparison.Ordinal); + + /// + public override bool Equals(object? obj) => obj is WorkingDirectoryOriginKind other && Equals(other); + + /// + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Ordinal equality over the raw wire value. + public static bool operator ==(WorkingDirectoryOriginKind left, WorkingDirectoryOriginKind right) => left.Equals(right); + + /// Ordinal inequality over the raw wire value. + public static bool operator !=(WorkingDirectoryOriginKind left, WorkingDirectoryOriginKind right) => !left.Equals(right); +} + +/// Reads and writes as its raw wire string, preserving unrecognized values. +internal sealed class WorkingDirectoryOriginKindConverter : JsonConverter +{ + /// + public override WorkingDirectoryOriginKind Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new WorkingDirectoryOriginKind(reader.GetString() ?? throw new JsonException("WorkingDirectoryOriginKind expects a JSON string.")); + + /// + public override void Write(Utf8JsonWriter writer, WorkingDirectoryOriginKind value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} + /// Discriminant for {@link ChatOrigin} — how a chat came into existence. [JsonConverter(typeof(ChatOriginKindConverter))] public readonly struct ChatOriginKind : IEquatable @@ -2152,6 +2205,39 @@ public sealed record MultipleWorkingDirectoriesCapability public bool? PrimaryReplacement { get; init; } } +/// An actual working directory, uniquely keyed by `uri` within the session. +public sealed record WorkingDirectory +{ + /// Actual selected directory, which may be a repository subdirectory. + public required string Uri { get; init; } + + /// Credential-free repository source association, not a checkout identity. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Repo { get; init; } + + /// Host-reported provenance; omission means unspecified. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WorkingDirectoryOrigin? Origin { get; init; } +} + +public sealed record LocalWorkingDirectoryOrigin +{ + public WorkingDirectoryOriginKind Kind { get; init; } = WorkingDirectoryOriginKind.Local; +} + +public sealed record RepoWorkingDirectoryOrigin +{ + public WorkingDirectoryOriginKind Kind { get; init; } = WorkingDirectoryOriginKind.Repo; +} + +public sealed record WorktreeWorkingDirectoryOrigin +{ + public WorkingDirectoryOriginKind Kind { get; init; } = WorkingDirectoryOriginKind.Worktree; + + /// Main worktree associated with the host-prepared worktree. + public required string MainWorktree { get; init; } +} + public sealed record SessionModelInfo { /// Model identifier @@ -2538,13 +2624,14 @@ public sealed class ChatState /// The subset of the session's /// {@link SessionState.workingDirectories | `workingDirectories`} that this - /// chat's agent has tool access to. Every entry MUST be present in the owning - /// session's `workingDirectories`; servers MUST reject a + /// chat's agent has tool access to. Every URI MUST match a URI string or a + /// record's `uri` in the owning session's set; servers MUST reject a /// `chat/workingDirectorySet` action that violates this constraint. /// /// When absent, the chat inherits the full session set. When present but empty /// (not recommended), the chat has no working-directory tool access at all. /// + /// Directory metadata belongs to the session, not the chat. /// Dispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to /// update the subset on a running chat. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -2991,9 +3078,11 @@ public sealed class SessionState /// (the first entry is a protected, replaceable primary slot). Individual chats /// MAY restrict to a subset via /// {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a - /// chat that sets none operates against this full set. + /// chat that sets none operates against this full set. + /// Entries are uniquely keyed by URI. Rich records require + /// {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List? WorkingDirectories { get; set; } + public List? WorkingDirectories { get; set; } /// Lightweight summary of this session's inline annotations channel /// (`ahp-annotations:/<uuid>`). Surfaced so badge UI can render @@ -3330,9 +3419,11 @@ public sealed class SessionSummary /// (the first entry is a protected, replaceable primary slot). Individual chats /// MAY restrict to a subset via /// {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a - /// chat that sets none operates against this full set. + /// chat that sets none operates against this full set. + /// Entries are uniquely keyed by URI. Rich records require + /// {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List? WorkingDirectories { get; set; } + public List? WorkingDirectories { get; set; } /// Lightweight summary of this session's inline annotations channel /// (`ahp-annotations:/<uuid>`). Surfaced so badge UI can render @@ -8035,6 +8126,34 @@ public SessionOriginConverter() } } +/// Host-reported working-directory provenance. +[JsonConverter(typeof(WorkingDirectoryOriginConverter))] +public sealed class WorkingDirectoryOrigin : AhpUnion +{ + /// Creates an empty WorkingDirectoryOrigin (no active variant). + public WorkingDirectoryOrigin() { } + + /// Creates a WorkingDirectoryOrigin wrapping the given variant value. + public WorkingDirectoryOrigin(object? value) : base(value) { } +} + +/// System.Text.Json converter for the WorkingDirectoryOrigin discriminated union. +internal sealed class WorkingDirectoryOriginConverter : UnionConverter +{ + public WorkingDirectoryOriginConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["local"] = typeof(LocalWorkingDirectoryOrigin), + ["repo"] = typeof(RepoWorkingDirectoryOrigin), + ["worktree"] = typeof(WorktreeWorkingDirectoryOrigin), + }, + allowUnknown: true) + { + } +} + /// AutomationTrigger is an automatic trigger for an automation. [JsonConverter(typeof(AutomationTriggerConverter))] public sealed class AutomationTrigger : AhpUnion @@ -8262,6 +8381,37 @@ public override void Write(Utf8JsonWriter writer, ToolInput value, JsonSerialize } } +/// A legacy URI or a complete working-directory record. +[JsonConverter(typeof(WorkingDirectoryEntryConverter))] +public sealed class WorkingDirectoryEntry +{ + public WorkingDirectoryEntry(string uri) { Uri = uri; } + public WorkingDirectoryEntry(WorkingDirectory directory) { Uri = directory.Uri; Directory = directory; } + + public string Uri { get; } + public WorkingDirectory? Directory { get; } +} + +internal sealed class WorkingDirectoryEntryConverter : JsonConverter +{ + public override WorkingDirectoryEntry Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return new WorkingDirectoryEntry(reader.GetString()!); + } + return new WorkingDirectoryEntry( + JsonSerializer.Deserialize(ref reader, AhpJsonTypeInfo.Get(options)) + ?? throw new JsonException("WorkingDirectoryEntry requires a URI or directory")); + } + + public override void Write(Utf8JsonWriter writer, WorkingDirectoryEntry value, JsonSerializerOptions options) + { + if (value.Directory is null) { writer.WriteStringValue(value.Uri); return; } + JsonSerializer.Serialize(writer, value.Directory, AhpJsonTypeInfo.Get(options)); + } +} + /// /// SnapshotState is the state payload of a snapshot — root, session, /// chat, canvas, terminal, changeset, resource-watch, annotations, automation catalogue, diff --git a/clients/dotnet/src/AgentHostProtocol/Reducers.cs b/clients/dotnet/src/AgentHostProtocol/Reducers.cs index dadc1c50f..664d6b29b 100644 --- a/clients/dotnet/src/AgentHostProtocol/Reducers.cs +++ b/clients/dotnet/src/AgentHostProtocol/Reducers.cs @@ -784,16 +784,20 @@ public static ReduceOutcome ApplyToSession(SessionState state, StateAction actio } case SessionWorkingDirectorySetAction a: { - // Membership keyed by the directory URI: append when the set does - // not already contain it (creating the set if absent), no-op when - // it is already present. Mirrors the TS reducer. - if (state.WorkingDirectories is not null && state.WorkingDirectories.Contains(a.Directory)) + state.WorkingDirectories ??= new List(); + int index = state.WorkingDirectories.FindIndex(d => d.Uri == a.Directory.Uri); + if (index < 0) + { + state.WorkingDirectories.Add(a.Directory); + } + else if (a.Directory.Directory is null) { return ReduceOutcome.NoOp; } - - state.WorkingDirectories ??= new List(); - state.WorkingDirectories.Add(a.Directory); + else + { + state.WorkingDirectories[index] = a.Directory; + } return ReduceOutcome.Applied; } case SessionWorkingDirectoryRemovedAction a: @@ -806,7 +810,7 @@ public static ReduceOutcome ApplyToSession(SessionState state, StateAction actio return ReduceOutcome.NoOp; } - int wdIdx = state.WorkingDirectories.IndexOf(a.Directory); + int wdIdx = state.WorkingDirectories.FindIndex(d => d.Uri == a.Directory); if (wdIdx < 0) { return ReduceOutcome.NoOp; @@ -822,26 +826,27 @@ public static ReduceOutcome ApplyToSession(SessionState state, StateAction actio return ReduceOutcome.NoOp; } - int index = state.WorkingDirectories.IndexOf(a.Directory); + int index = state.WorkingDirectories.FindIndex(d => d.Uri == a.Directory); if (index < 0) { return ReduceOutcome.NoOp; } - int replacementIndex = state.WorkingDirectories.IndexOf(a.Replacement); + int replacementIndex = state.WorkingDirectories.FindIndex(d => d.Uri == a.Replacement.Uri); + var replacement = a.Replacement.Directory is null && replacementIndex >= 0 + ? state.WorkingDirectories[replacementIndex] + : a.Replacement; if (replacementIndex >= 0 && replacementIndex < index) { + state.WorkingDirectories[replacementIndex] = replacement; state.WorkingDirectories.RemoveAt(index); return ReduceOutcome.Applied; } - state.WorkingDirectories[index] = a.Replacement; - for (int i = state.WorkingDirectories.Count - 1; i >= 0; i--) + state.WorkingDirectories[index] = replacement; + if (replacementIndex > index) { - if (i != index && state.WorkingDirectories[i] == a.Replacement) - { - state.WorkingDirectories.RemoveAt(i); - } + state.WorkingDirectories.RemoveAt(replacementIndex); } return ReduceOutcome.Applied; diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs index 447313763..363c02d4d 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs @@ -164,6 +164,18 @@ private static (object decoded, string reencoded) DecodeAndReencode(string type, return Wrap(Ser.Deserialize(inputJson)); case "InitializeResult": return Wrap(Ser.Deserialize(inputJson)); + case "InitializeParams": + return Wrap(Ser.Deserialize(inputJson)); + case "ListSessionsResult": + return Wrap(Ser.Deserialize(inputJson)); + case "SessionSummaryChangedParams": + return Wrap(Ser.Deserialize(inputJson)); + case "CreateSessionParams": + return Wrap(Ser.Deserialize(inputJson)); + case "ResolveSessionConfigParams": + return Wrap(Ser.Deserialize(inputJson)); + case "SessionConfigCompletionsParams": + return Wrap(Ser.Deserialize(inputJson)); case "CreateChatParams": return Wrap(Ser.Deserialize(inputJson)); case "Snapshot": diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 22fe67c01..404ebe39b 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -1058,16 +1058,20 @@ func ApplyActionToSession(state *ahptypes.SessionState, action ahptypes.StateAct } return ReduceOutcomeNoOp case *ahptypes.SessionWorkingDirectorySetAction: - for _, d := range state.WorkingDirectories { - if d == a.Directory { - return ReduceOutcomeNoOp + for i, d := range state.WorkingDirectories { + if d.GetURI() == a.Directory.GetURI() { + if a.Directory.Directory == nil { + return ReduceOutcomeNoOp + } + state.WorkingDirectories[i] = a.Directory + return ReduceOutcomeApplied } } state.WorkingDirectories = append(state.WorkingDirectories, a.Directory) return ReduceOutcomeApplied case *ahptypes.SessionWorkingDirectoryRemovedAction: for i := range state.WorkingDirectories { - if state.WorkingDirectories[i] == a.Directory { + if state.WorkingDirectories[i].GetURI() == a.Directory { state.WorkingDirectories = append(state.WorkingDirectories[:i], state.WorkingDirectories[i+1:]...) return ReduceOutcomeApplied } @@ -1076,7 +1080,7 @@ func ApplyActionToSession(state *ahptypes.SessionState, action ahptypes.StateAct case *ahptypes.SessionWorkingDirectoryReplacedAction: idx := -1 for i, directory := range state.WorkingDirectories { - if directory == a.Directory { + if directory.GetURI() == a.Directory { idx = i break } @@ -1084,21 +1088,26 @@ func ApplyActionToSession(state *ahptypes.SessionState, action ahptypes.StateAct if idx < 0 { return ReduceOutcomeNoOp } - for i := 0; i < idx; i++ { - if state.WorkingDirectories[i] == a.Replacement { - state.WorkingDirectories = append(state.WorkingDirectories[:idx], state.WorkingDirectories[idx+1:]...) - return ReduceOutcomeApplied + replacementIdx := -1 + for i, directory := range state.WorkingDirectories { + if directory.GetURI() == a.Replacement.GetURI() { + replacementIdx = i + break } } - updated := make([]ahptypes.URI, 0, len(state.WorkingDirectories)) - for i, directory := range state.WorkingDirectories { - if i == idx { - updated = append(updated, a.Replacement) - } else if directory != a.Replacement { - updated = append(updated, directory) + replacement := a.Replacement + if replacement.Directory == nil && replacementIdx >= 0 { + replacement = state.WorkingDirectories[replacementIdx] + } + if replacementIdx >= 0 && replacementIdx < idx { + state.WorkingDirectories[replacementIdx] = replacement + state.WorkingDirectories = append(state.WorkingDirectories[:idx], state.WorkingDirectories[idx+1:]...) + } else { + state.WorkingDirectories[idx] = replacement + if replacementIdx > idx { + state.WorkingDirectories = append(state.WorkingDirectories[:replacementIdx], state.WorkingDirectories[replacementIdx+1:]...) } } - state.WorkingDirectories = updated return ReduceOutcomeApplied case *ahptypes.SessionInputNeededSetAction: id, ok := sessionInputRequestID(a.Request) diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index cfb71a816..e0cc3dfc5 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -1079,17 +1079,18 @@ type SessionActiveClientRemovedAction struct { ClientId string `json:"clientId"` } -// A working directory was added to the session's +// A working directory was added or updated in the session's // {@link SessionState.workingDirectories} set. // -// Membership semantics keyed by the directory URI: the reducer appends -// `directory` when the set does not already contain it (creating the set if -// absent) and is a no-op when it is already present. Only valid when the agent -// advertises {@link AgentCapabilities.multipleWorkingDirectories}. +// Upsert keyed by URI. A rich record replaces the existing entry in place; +// a URI-only duplicate preserves known metadata. Only valid when the agent +// advertises {@link AgentCapabilities.multipleWorkingDirectories}. Clients +// MUST omit `repo` and use only local or absent `origin`; the host validates +// and enriches the entry before accepting it. type SessionWorkingDirectorySetAction struct { Type ActionType `json:"type"` // The working directory to grant the session's agent tool access to. - Directory URI `json:"directory"` + Directory WorkingDirectoryEntry `json:"directory"` } // A working directory was removed from the session's @@ -1120,6 +1121,8 @@ type SessionWorkingDirectoryRemovedAction struct { // for example, `[A, B, C]` with `B → C` becomes `[A, C]`. When it occurs // before the target, it retains its earlier position and the target is removed; // `[A, B, C]` with `C → A` becomes `[A, B]`. +// A rich replacement supplies the complete entry; a URI-only replacement +// preserves metadata already known for that URI. // // Only valid when the agent advertises // {@link AgentCapabilities.multipleWorkingDirectories}. Replacing index `0` @@ -1127,12 +1130,13 @@ type SessionWorkingDirectoryRemovedAction struct { // {@link MultipleWorkingDirectoriesCapability.primaryReplacement}; clients // MUST NOT target an immutable primary. The host MUST validate and apply its // backend side effect before broadcasting an accepted action, or reject it. +// Clients MUST omit `repo` and use only local or absent `origin`. type SessionWorkingDirectoryReplacedAction struct { Type ActionType `json:"type"` // URI of the existing entry to replace. Directory URI `json:"directory"` - // URI to place in the replaced entry's position. - Replacement URI `json:"replacement"` + // Complete entry to place in the replaced entry's position. + Replacement WorkingDirectoryEntry `json:"replacement"` } // A working directory was added to this chat's @@ -1141,8 +1145,9 @@ type SessionWorkingDirectoryReplacedAction struct { // Membership semantics keyed by the directory URI: the reducer appends // `directory` when the chat's subset does not already contain it (creating the // subset if absent) and is a no-op when it is already present. `directory` MUST -// be one of the owning session's {@link SessionState.workingDirectories}; a host -// MUST reject a directory that is not. Only valid when the agent advertises +// match a URI string or record's `uri` in the owning session's +// {@link SessionState.workingDirectories}; a host MUST reject a directory that +// does not. Only valid when the agent advertises // {@link AgentCapabilities.multipleWorkingDirectories}. type ChatWorkingDirectorySetAction struct { Type ActionType `json:"type"` diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index 77443e810..78a5914c8 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -114,6 +114,16 @@ const ( // ─── Command Payloads ───────────────────────────────────────────────── +// Requested repository source, not a resolved working directory. +type RepositorySource struct { + // Credential-free repository source URI. + Source URI `json:"source"` + // Requested branch, tag, or commit. Omit to use the host's default revision. + Revision *string `json:"revision,omitempty"` + // Repository-relative selected folder; omit for the root. Hosts reject empty, absolute, or escaping paths. + Subdirectory *string `json:"subdirectory,omitempty"` +} + // Opens a TCP connection from the host to a destination on the host's network. // // The client chooses the channel URI. This command does not subscribe: after it @@ -243,6 +253,8 @@ type InitializeResult struct { Environment *HostEnvironment `json:"environment,omitempty"` // Suggested default directory for remote filesystem browsing DefaultDirectory *URI `json:"defaultDirectory,omitempty"` + // Host repository preparation support; absent when unsupported. + RepositoryPreparation *RepositoryPreparationCapability `json:"repositoryPreparation,omitempty"` // Characters that, when typed in a {@link Message} input, SHOULD cause // the client to issue a `completions` request with // {@link CompletionItemKind.UserMessage}. Typically includes characters like @@ -277,12 +289,28 @@ type InitializeResult struct { MultipleChatProviders *MultipleChatProvidersCapability `json:"multipleChatProviders,omitempty"` } +// Host support for repository preparation at session creation. Presence +// alone supports one repository at its default revision. +type RepositoryPreparationCapability struct { + // When true, clients may supply {@link RepositorySource.revision}. + Revision *bool `json:"revision,omitempty"` + // When true, clients may supply more than one repository. + MultipleRepositories *bool `json:"multipleRepositories,omitempty"` +} + +// Presence capability for {@link ClientCapabilities.workingDirectoryInfo}. +type WorkingDirectoryInfoCapability struct { +} + // Optional capabilities a client declares during `initialize`. // // Each field is a presence flag: an empty object `{}` means "supported", // absence means "not supported". Sub-fields on individual capabilities // are reserved for future per-capability options. type ClientCapabilities struct { + // Client accepts rich {@link WorkingDirectory} records as well as URI strings. + // Hosts project records to URIs when absent and retain this choice on reconnect. + WorkingDirectoryInfo *WorkingDirectoryInfoCapability `json:"workingDirectoryInfo,omitempty"` // Client can render // [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) — i.e. // it can host the View sandbox, run the `ui/*` protocol against it, @@ -601,6 +629,9 @@ type CreateSessionParams struct { // and ignores the rest. Dispatch working-directory actions to change the set // after the session has started. WorkingDirectories []URI `json:"workingDirectories,omitempty"` + // Repositories to prepare instead of an explicit `workingDirectories` list. + // An empty list is equivalent to omission. + Repositories []RepositorySource `json:"repositories,omitempty"` // Agent-specific configuration values collected via `resolveSessionConfig`. // Keys and values correspond to the schema returned by the server. Config map[string]json.RawMessage `json:"config,omitempty"` @@ -697,9 +728,9 @@ type CreateChatParams struct { // also snapshots and preserves that exact selected text in the created chat's // origin; any `responsePartId` there is provenance only, not a live range. Source *ChatSource `json:"source,omitempty"` - // Initial working-directory subset for this chat. Every entry MUST be - // present in the owning session's `workingDirectories`; the server MUST - // reject any entry that is not. When absent, the chat inherits the full + // Initial working-directory URI subset for this chat. Every URI MUST match + // a URI string or record's `uri` in the owning session's `workingDirectories`; + // the server MUST reject any entry that does not. When absent, the chat inherits the full // session set. Forked chats (those whose `source.kind` is `"fork"`) inherit // the source chat's `workingDirectories`; this field is ignored for forks. // @@ -1440,6 +1471,8 @@ type ResolveSessionConfigParams struct { Provider *string `json:"provider,omitempty"` // Working directory for the session WorkingDirectory *URI `json:"workingDirectory,omitempty"` + // Repositories used as configuration context; an empty list is equivalent to omission. + Repositories []RepositorySource `json:"repositories,omitempty"` // Current user-filled configuration values Config map[string]json.RawMessage `json:"config,omitempty"` } @@ -1467,6 +1500,8 @@ type SessionConfigCompletionsParams struct { Provider *string `json:"provider,omitempty"` // Working directory for the session WorkingDirectory *URI `json:"workingDirectory,omitempty"` + // Repositories used as configuration context; an empty list is equivalent to omission. + Repositories []RepositorySource `json:"repositories,omitempty"` // Current user-filled configuration values (provides context for the query) Config map[string]json.RawMessage `json:"config,omitempty"` // Property id from the schema to query values for diff --git a/clients/go/ahptypes/notifications.generated.go b/clients/go/ahptypes/notifications.generated.go index 5a5e955ad..5f0d42b3f 100644 --- a/clients/go/ahptypes/notifications.generated.go +++ b/clients/go/ahptypes/notifications.generated.go @@ -358,7 +358,9 @@ type PartialSessionSummary struct { // MAY restrict to a subset via // {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a // chat that sets none operates against this full set. - WorkingDirectories []URI `json:"workingDirectories,omitempty"` + // Entries are uniquely keyed by URI. Rich records require + // {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. + WorkingDirectories []WorkingDirectoryEntry `json:"workingDirectories,omitempty"` // Lightweight summary of this session's inline annotations channel // (`ahp-annotations:/`). Surfaced so badge UI can render // annotation / entry counts without subscribing. Absent when the session diff --git a/clients/go/ahptypes/roundtrip_fixture_test.go b/clients/go/ahptypes/roundtrip_fixture_test.go index c8442d822..57e448a69 100644 --- a/clients/go/ahptypes/roundtrip_fixture_test.go +++ b/clients/go/ahptypes/roundtrip_fixture_test.go @@ -258,6 +258,38 @@ func decodeAndReencode(t *testing.T, name, typ, inputJSON string) string { var v InitializeResult dec(&v) return enc(&v) + case "InitializeParams": + var v InitializeParams + dec(&v) + return enc(&v) + case "SubscribeResult": + var v SubscribeResult + dec(&v) + return enc(&v) + case "ReconnectResult": + var v ReconnectResult + dec(&v) + return enc(&v) + case "ListSessionsResult": + var v ListSessionsResult + dec(&v) + return enc(&v) + case "SessionSummaryChangedParams": + var v SessionSummaryChangedParams + dec(&v) + return enc(&v) + case "CreateSessionParams": + var v CreateSessionParams + dec(&v) + return enc(&v) + case "ResolveSessionConfigParams": + var v ResolveSessionConfigParams + dec(&v) + return enc(&v) + case "SessionConfigCompletionsParams": + var v SessionConfigCompletionsParams + dec(&v) + return enc(&v) case "CreateChatParams": var v CreateChatParams dec(&v) @@ -274,14 +306,6 @@ func decodeAndReencode(t *testing.T, name, typ, inputJSON string) string { var v SubscribeParams dec(&v) return enc(&v) - case "SubscribeResult": - var v SubscribeResult - dec(&v) - return enc(&v) - case "ReconnectResult": - var v ReconnectResult - dec(&v) - return enc(&v) case "ChannelFrameParams": var v ChannelFrameParams dec(&v) diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 545273128..464424970 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -6,13 +6,9 @@ package ahptypes import ( "encoding/json" + "fmt" ) -// Reference the encoding/json import to keep gofmt -d from -// stripping it when a generated file has no struct that mentions -// json.RawMessage directly (rare but possible). Compiled out. -var _ = json.RawMessage(nil) - // ─── Enums ──────────────────────────────────────────────────────────── // How the server resumes sending data on a windowed subscription after @@ -517,6 +513,15 @@ const ( SessionOriginKindAutomation SessionOriginKind = "automation" ) +// How a working directory was prepared. +type WorkingDirectoryOriginKind string + +const ( + WorkingDirectoryOriginKindLocal WorkingDirectoryOriginKind = "local" + WorkingDirectoryOriginKindRepo WorkingDirectoryOriginKind = "repo" + WorkingDirectoryOriginKindWorktree WorkingDirectoryOriginKind = "worktree" +) + // Operations the host currently permits for an automation. // // The list on {@link AutomationEntry.operations} is authoritative and may @@ -895,6 +900,30 @@ type MultipleWorkingDirectoriesCapability struct { PrimaryReplacement *bool `json:"primaryReplacement,omitempty"` } +// An actual working directory, uniquely keyed by `uri` within the session. +type WorkingDirectory struct { + // Actual selected directory, which may be a repository subdirectory. + Uri URI `json:"uri"` + // Credential-free repository source association, not a checkout identity. + Repo *URI `json:"repo,omitempty"` + // Host-reported provenance; omission means unspecified. + Origin *WorkingDirectoryOrigin `json:"origin,omitempty"` +} + +type LocalWorkingDirectoryOrigin struct { + Kind WorkingDirectoryOriginKind `json:"kind"` +} + +type RepoWorkingDirectoryOrigin struct { + Kind WorkingDirectoryOriginKind `json:"kind"` +} + +type WorktreeWorkingDirectoryOrigin struct { + Kind WorkingDirectoryOriginKind `json:"kind"` + // Main worktree associated with the host-prepared worktree. + MainWorktree URI `json:"mainWorktree"` +} + type SessionModelInfo struct { // Model identifier Id string `json:"id"` @@ -1063,7 +1092,9 @@ type SessionState struct { // MAY restrict to a subset via // {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a // chat that sets none operates against this full set. - WorkingDirectories []URI `json:"workingDirectories,omitempty"` + // Entries are uniquely keyed by URI. Rich records require + // {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. + WorkingDirectories []WorkingDirectoryEntry `json:"workingDirectories,omitempty"` // Lightweight summary of this session's inline annotations channel // (`ahp-annotations:/`). Surfaced so badge UI can render // annotation / entry counts without subscribing. Absent when the session @@ -1343,7 +1374,9 @@ type SessionSummary struct { // MAY restrict to a subset via // {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a // chat that sets none operates against this full set. - WorkingDirectories []URI `json:"workingDirectories,omitempty"` + // Entries are uniquely keyed by URI. Rich records require + // {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. + WorkingDirectories []WorkingDirectoryEntry `json:"workingDirectories,omitempty"` // Lightweight summary of this session's inline annotations channel // (`ahp-annotations:/`). Surfaced so badge UI can render // annotation / entry counts without subscribing. Absent when the session @@ -1472,13 +1505,14 @@ type ChatState struct { Provider *string `json:"provider,omitempty"` // The subset of the session's // {@link SessionState.workingDirectories | `workingDirectories`} that this - // chat's agent has tool access to. Every entry MUST be present in the owning - // session's `workingDirectories`; servers MUST reject a + // chat's agent has tool access to. Every URI MUST match a URI string or a + // record's `uri` in the owning session's set; servers MUST reject a // `chat/workingDirectorySet` action that violates this constraint. // // When absent, the chat inherits the full session set. When present but empty // (not recommended), the chat has no working-directory tool access at all. // + // Directory metadata belongs to the session, not the chat. // Dispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to // update the subset on a running chat. WorkingDirectories []URI `json:"workingDirectories,omitempty"` @@ -5340,6 +5374,50 @@ func (t *ToolInput) UnmarshalJSON(data []byte) error { return nil } +// WorkingDirectoryEntry is a legacy URI or a complete working-directory record. +type WorkingDirectoryEntry struct { + URI *URI + Directory *WorkingDirectory +} + +func (d WorkingDirectoryEntry) GetURI() URI { + if d.Directory != nil { + return d.Directory.Uri + } + if d.URI != nil { + return *d.URI + } + return "" +} + +func (d WorkingDirectoryEntry) MarshalJSON() ([]byte, error) { + if d.Directory != nil { + return json.Marshal(d.Directory) + } + if d.URI != nil { + return json.Marshal(*d.URI) + } + return nil, fmt.Errorf("WorkingDirectoryEntry requires a URI or directory") +} + +func (d *WorkingDirectoryEntry) UnmarshalJSON(data []byte) error { + *d = WorkingDirectoryEntry{} + var uri *URI + if err := json.Unmarshal(data, &uri); err == nil && uri != nil { + d.URI = uri + return nil + } + var directory *WorkingDirectory + if err := json.Unmarshal(data, &directory); err != nil { + return err + } + if directory == nil { + return fmt.Errorf("WorkingDirectoryEntry requires a URI or directory") + } + d.Directory = directory + return nil +} + // ─── Discriminated Unions ───────────────────────────────────────────── // ResponsePart is a single part of a response stream (text, tool call, reasoning, content reference). @@ -6759,6 +6837,92 @@ func (u SessionOrigin) MarshalJSON() ([]byte, error) { return json.Marshal(object) } +// WorkingDirectoryOrigin is the host-reported working-directory provenance. +type WorkingDirectoryOrigin struct { + Value isWorkingDirectoryOrigin +} + +// isWorkingDirectoryOrigin is the marker interface implemented by every +// concrete variant of WorkingDirectoryOrigin. +type isWorkingDirectoryOrigin interface{ isWorkingDirectoryOrigin() } + +func (*LocalWorkingDirectoryOrigin) isWorkingDirectoryOrigin() {} +func (*RepoWorkingDirectoryOrigin) isWorkingDirectoryOrigin() {} +func (*WorktreeWorkingDirectoryOrigin) isWorkingDirectoryOrigin() {} + +// WorkingDirectoryOriginUnknown carries an unrecognized WorkingDirectoryOrigin 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 WorkingDirectoryOriginUnknown struct { + Raw json.RawMessage +} + +func (*WorkingDirectoryOriginUnknown) isWorkingDirectoryOrigin() {} + +// UnmarshalJSON decodes the variant indicated by the "kind" discriminator. +func (u *WorkingDirectoryOrigin) UnmarshalJSON(data []byte) error { + disc, _, err := readDiscriminator(data, "kind") + if err != nil { + return err + } + switch disc { + case "local": + var value LocalWorkingDirectoryOrigin + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "repo": + var value RepoWorkingDirectoryOrigin + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "worktree": + var value WorktreeWorkingDirectoryOrigin + 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 = &WorkingDirectoryOriginUnknown{Raw: raw} + } + return nil +} + +// MarshalJSON encodes the active variant back to JSON. +func (u WorkingDirectoryOrigin) MarshalJSON() ([]byte, error) { + if unk, ok := u.Value.(*WorkingDirectoryOriginUnknown); ok { + if len(unk.Raw) == 0 { + return []byte("null"), nil + } + return unk.Raw, nil + } + if u.Value == nil { + return []byte("null"), nil + } + data, err := json.Marshal(u.Value) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err != nil { + return nil, err + } + if object == nil { + return data, nil + } + switch u.Value.(type) { + case *LocalWorkingDirectoryOrigin: + object["kind"] = json.RawMessage("\"local\"") + case *RepoWorkingDirectoryOrigin: + object["kind"] = json.RawMessage("\"repo\"") + case *WorktreeWorkingDirectoryOrigin: + object["kind"] = json.RawMessage("\"worktree\"") + } + return json.Marshal(object) +} + // AutomationTrigger is an automatic trigger for an automation. type AutomationTrigger struct { Value isAutomationTrigger 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 dd291e656..b2799ed38 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -708,16 +708,21 @@ public fun sessionReducer(state: SessionState, action: StateAction): SessionStat is StateActionSessionWorkingDirectorySet -> { val list = state.workingDirectories ?: emptyList() - if (list.contains(action.value.directory)) { + val idx = list.indexOfFirst { it.uri == action.value.directory.uri } + if (idx < 0) { + state.copy(workingDirectories = list + action.value.directory) + } else if (action.value.directory is WorkingDirectoryEntry.Uri) { state } else { - state.copy(workingDirectories = list + action.value.directory) + val updated = list.toMutableList() + updated[idx] = action.value.directory + state.copy(workingDirectories = updated) } } is StateActionSessionWorkingDirectoryRemoved -> { val list = state.workingDirectories - val idx = list?.indexOf(action.value.directory) ?: -1 + val idx = list?.indexOfFirst { it.uri == action.value.directory } ?: -1 if (list == null || idx < 0) { state } else { @@ -729,24 +734,27 @@ public fun sessionReducer(state: SessionState, action: StateAction): SessionStat is StateActionSessionWorkingDirectoryReplaced -> { val list = state.workingDirectories - val idx = list?.indexOf(action.value.directory) ?: -1 + val idx = list?.indexOfFirst { it.uri == action.value.directory } ?: -1 if (list == null || idx < 0) { state } else { - val replacementIdx = list.indexOf(action.value.replacement) + val replacementIdx = list.indexOfFirst { it.uri == action.value.replacement.uri } + val replacement = if (action.value.replacement is WorkingDirectoryEntry.Uri && replacementIdx >= 0) { + list[replacementIdx] + } else { + action.value.replacement + } + val updated = list.toMutableList() if (replacementIdx in 0 until idx) { - state.copy(workingDirectories = list.filterIndexed { index, _ -> index != idx }) + updated[replacementIdx] = replacement + updated.removeAt(idx) } else { - state.copy( - workingDirectories = list.mapIndexedNotNull { index, directory -> - when { - index == idx -> action.value.replacement - directory == action.value.replacement -> null - else -> directory - } - }, - ) + updated[idx] = replacement + if (replacementIdx > idx) { + updated.removeAt(replacementIdx) + } } + state.copy(workingDirectories = updated) } } 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 70d6a8322..eb743eb26 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 @@ -943,7 +943,7 @@ data class SessionWorkingDirectorySetAction( /** * The working directory to grant the session's agent tool access to. */ - val directory: String + val directory: WorkingDirectoryEntry ) @Serializable @@ -963,9 +963,9 @@ data class SessionWorkingDirectoryReplacedAction( */ val directory: String, /** - * URI to place in the replaced entry's position. + * Complete entry to place in the replaced entry's position. */ - val replacement: String + val replacement: WorkingDirectoryEntry ) @Serializable diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt index 12b5b8017..1528406fd 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt @@ -330,6 +330,22 @@ internal object SideChatSourceSerializer : KSerializer { } } +@Serializable +data class RepositorySource( + /** + * Credential-free repository source URI. + */ + val source: String, + /** + * Requested branch, tag, or commit. Omit to use the host's default revision. + */ + val revision: String? = null, + /** + * Repository-relative selected folder; omit for the root. Hosts reject empty, absolute, or escaping paths. + */ + val subdirectory: String? = null +) + /** * Opens a TCP connection from the host to a destination on the host's network. * @@ -491,6 +507,10 @@ data class InitializeResult( * Suggested default directory for remote filesystem browsing */ val defaultDirectory: String? = null, + /** + * Host repository preparation support; absent when unsupported. + */ + val repositoryPreparation: RepositoryPreparationCapability? = null, /** * Characters that, when typed in a {@link Message} input, SHOULD cause * the client to issue a `completions` request with @@ -537,8 +557,28 @@ data class InitializeResult( val multipleChatProviders: MultipleChatProvidersCapability? = null ) +@Serializable +data class RepositoryPreparationCapability( + /** + * When true, clients may supply {@link RepositorySource.revision}. + */ + val revision: Boolean? = null, + /** + * When true, clients may supply more than one repository. + */ + val multipleRepositories: Boolean? = null +) + +@Serializable +class WorkingDirectoryInfoCapability + @Serializable data class ClientCapabilities( + /** + * Client accepts rich {@link WorkingDirectory} records as well as URI strings. + * Hosts project records to URIs when absent and retain this choice on reconnect. + */ + val workingDirectoryInfo: WorkingDirectoryInfoCapability? = null, /** * Client can render * [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) — i.e. @@ -893,6 +933,11 @@ data class CreateSessionParams( * after the session has started. */ val workingDirectories: List? = null, + /** + * Repositories to prepare instead of an explicit `workingDirectories` list. + * An empty list is equivalent to omission. + */ + val repositories: List? = null, /** * Agent-specific configuration values collected via `resolveSessionConfig`. * Keys and values correspond to the schema returned by the server. @@ -974,9 +1019,9 @@ data class CreateChatParams( */ val source: ChatSource? = null, /** - * Initial working-directory subset for this chat. Every entry MUST be - * present in the owning session's `workingDirectories`; the server MUST - * reject any entry that is not. When absent, the chat inherits the full + * Initial working-directory URI subset for this chat. Every URI MUST match + * a URI string or record's `uri` in the owning session's `workingDirectories`; + * the server MUST reject any entry that does not. When absent, the chat inherits the full * session set. Forked chats (those whose `source.kind` is `"fork"`) inherit * the source chat's `workingDirectories`; this field is ignored for forks. * @@ -1719,6 +1764,10 @@ data class ResolveSessionConfigParams( * Working directory for the session */ val workingDirectory: String? = null, + /** + * Repositories used as configuration context; an empty list is equivalent to omission. + */ + val repositories: List? = null, /** * Current user-filled configuration values */ @@ -1849,6 +1898,10 @@ data class SessionConfigCompletionsParams( * Working directory for the session */ val workingDirectory: String? = null, + /** + * Repositories used as configuration context; an empty list is equivalent to omission. + */ + val repositories: List? = null, /** * Current user-filled configuration values (provides context for the query) */ diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt index 02a8aefe9..9a09ee108 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt @@ -390,8 +390,10 @@ data class PartialSessionSummary( * MAY restrict to a subset via * {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a * chat that sets none operates against this full set. + * Entries are uniquely keyed by URI. Rich records require + * {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. */ - val workingDirectories: List? = null, + val workingDirectories: List? = null, /** * Lightweight summary of this session's inline annotations channel * (`ahp-annotations:/`). Surfaced so badge UI can render 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 21449a12f..af84dc502 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 @@ -1102,6 +1102,29 @@ internal object SessionOriginKindSerializer : KSerializer { SessionOriginKind(decoder.decodeString()) } +/** + * How a working directory was prepared. + */ +@Serializable(with = WorkingDirectoryOriginKindSerializer::class) +@JvmInline +value class WorkingDirectoryOriginKind(val rawValue: String) { + companion object { + val LOCAL: WorkingDirectoryOriginKind = WorkingDirectoryOriginKind("local") + val REPO: WorkingDirectoryOriginKind = WorkingDirectoryOriginKind("repo") + val WORKTREE: WorkingDirectoryOriginKind = WorkingDirectoryOriginKind("worktree") + } +} + +internal object WorkingDirectoryOriginKindSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("WorkingDirectoryOriginKind", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: WorkingDirectoryOriginKind) { + encoder.encodeString(value.rawValue) + } + override fun deserialize(decoder: Decoder): WorkingDirectoryOriginKind = + WorkingDirectoryOriginKind(decoder.decodeString()) +} + /** * Operations the host currently permits for an automation. * @@ -1647,6 +1670,41 @@ data class MultipleWorkingDirectoriesCapability( val primaryReplacement: Boolean? = null ) +@Serializable +data class WorkingDirectory( + /** + * Actual selected directory, which may be a repository subdirectory. + */ + val uri: String, + /** + * Credential-free repository source association, not a checkout identity. + */ + val repo: String? = null, + /** + * Host-reported provenance; omission means unspecified. + */ + val origin: WorkingDirectoryOrigin? = null +) + +@Serializable +data class LocalWorkingDirectoryOrigin( + val kind: WorkingDirectoryOriginKind +) + +@Serializable +data class RepoWorkingDirectoryOrigin( + val kind: WorkingDirectoryOriginKind +) + +@Serializable +data class WorktreeWorkingDirectoryOrigin( + val kind: WorkingDirectoryOriginKind, + /** + * Main worktree associated with the host-prepared worktree. + */ + val mainWorktree: String +) + @Serializable data class SessionModelInfo( /** @@ -1902,13 +1960,14 @@ data class ChatState( /** * The subset of the session's * {@link SessionState.workingDirectories | `workingDirectories`} that this - * chat's agent has tool access to. Every entry MUST be present in the owning - * session's `workingDirectories`; servers MUST reject a + * chat's agent has tool access to. Every URI MUST match a URI string or a + * record's `uri` in the owning session's set; servers MUST reject a * `chat/workingDirectorySet` action that violates this constraint. * * When absent, the chat inherits the full session set. When present but empty * (not recommended), the chat has no working-directory tool access at all. * + * Directory metadata belongs to the session, not the chat. * Dispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to * update the subset on a running chat. */ @@ -2113,8 +2172,10 @@ data class SessionState( * MAY restrict to a subset via * {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a * chat that sets none operates against this full set. + * Entries are uniquely keyed by URI. Rich records require + * {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. */ - val workingDirectories: List? = null, + val workingDirectories: List? = null, /** * Lightweight summary of this session's inline annotations channel * (`ahp-annotations:/`). Surfaced so badge UI can render @@ -2518,8 +2579,10 @@ data class SessionSummary( * MAY restrict to a subset via * {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a * chat that sets none operates against this full set. + * Entries are uniquely keyed by URI. Rich records require + * {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. */ - val workingDirectories: List? = null, + val workingDirectories: List? = null, /** * Lightweight summary of this session's inline annotations channel * (`ahp-annotations:/`). Surfaced so badge UI can render @@ -6901,6 +6964,50 @@ internal object ToolInputSerializer : KSerializer { } } +/** A legacy URI or a complete working-directory record. */ +@Serializable(with = WorkingDirectoryEntrySerializer::class) +sealed interface WorkingDirectoryEntry { + val uri: String + + data class Uri(val value: String) : WorkingDirectoryEntry { + override val uri: String get() = value + } + data class Directory(val value: WorkingDirectory) : WorkingDirectoryEntry { + override val uri: String get() = value.uri + } +} + +internal object WorkingDirectoryEntrySerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("WorkingDirectoryEntry") + + override fun deserialize(decoder: Decoder): WorkingDirectoryEntry { + val input = decoder as? JsonDecoder + ?: error("WorkingDirectoryEntry requires JSON") + return when (val element = input.decodeJsonElement()) { + is JsonPrimitive -> { + require(element.isString) { "WorkingDirectoryEntry URI must be a string" } + WorkingDirectoryEntry.Uri(element.content) + } + is JsonObject -> WorkingDirectoryEntry.Directory( + input.json.decodeFromJsonElement(WorkingDirectory.serializer(), element), + ) + else -> error("WorkingDirectoryEntry requires a URI or directory") + } + } + + override fun serialize(encoder: Encoder, value: WorkingDirectoryEntry) { + val output = encoder as? JsonEncoder + ?: error("WorkingDirectoryEntry requires JSON") + val element = when (value) { + is WorkingDirectoryEntry.Uri -> JsonPrimitive(value.value) + is WorkingDirectoryEntry.Directory -> + output.json.encodeToJsonElement(WorkingDirectory.serializer(), value.value) + } + output.encodeJsonElement(element) + } +} + // ─── Discriminated Unions ─────────────────────────────────────────────────── @Serializable(with = ChatOriginSerializer::class) @@ -7982,6 +8089,67 @@ internal object SessionOriginSerializer : KSerializer { } } +@Serializable(with = WorkingDirectoryOriginSerializer::class) +sealed interface WorkingDirectoryOrigin + +@JvmInline +value class WorkingDirectoryOriginLocal(val value: LocalWorkingDirectoryOrigin) : WorkingDirectoryOrigin +@JvmInline +value class WorkingDirectoryOriginRepo(val value: RepoWorkingDirectoryOrigin) : WorkingDirectoryOrigin +@JvmInline +value class WorkingDirectoryOriginWorktree(val value: WorktreeWorkingDirectoryOrigin) : WorkingDirectoryOrigin +/** + * Forward-compat catch-all for unknown WorkingDirectoryOrigin 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 WorkingDirectoryOriginUnknown(val raw: JsonObject) : WorkingDirectoryOrigin + +internal object WorkingDirectoryOriginSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("WorkingDirectoryOrigin") + + override fun deserialize(decoder: Decoder): WorkingDirectoryOrigin { + val input = decoder as? JsonDecoder + ?: error("WorkingDirectoryOrigin can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for WorkingDirectoryOrigin") + val discriminant = (obj["kind"] as? JsonPrimitive)?.content + ?: return WorkingDirectoryOriginUnknown(obj) + return when (discriminant) { + "local" -> WorkingDirectoryOriginLocal(input.json.decodeFromJsonElement(LocalWorkingDirectoryOrigin.serializer(), element)) + "repo" -> WorkingDirectoryOriginRepo(input.json.decodeFromJsonElement(RepoWorkingDirectoryOrigin.serializer(), element)) + "worktree" -> WorkingDirectoryOriginWorktree(input.json.decodeFromJsonElement(WorktreeWorkingDirectoryOrigin.serializer(), element)) + else -> WorkingDirectoryOriginUnknown(obj) + } + } + + override fun serialize(encoder: Encoder, value: WorkingDirectoryOrigin) { + val output = encoder as? JsonEncoder + ?: error("WorkingDirectoryOrigin can only be serialized to JSON") + val element: JsonElement = when (value) { + is WorkingDirectoryOriginLocal -> output.json.encodeToJsonElement(LocalWorkingDirectoryOrigin.serializer(), value.value) + is WorkingDirectoryOriginRepo -> output.json.encodeToJsonElement(RepoWorkingDirectoryOrigin.serializer(), value.value) + is WorkingDirectoryOriginWorktree -> output.json.encodeToJsonElement(WorktreeWorkingDirectoryOrigin.serializer(), value.value) + is WorkingDirectoryOriginUnknown -> value.raw + } + val encodedObject = element.jsonObject.toMutableMap() + val discriminant = when (value) { + is WorkingDirectoryOriginLocal -> "local" + is WorkingDirectoryOriginRepo -> "repo" + is WorkingDirectoryOriginWorktree -> "worktree" + is WorkingDirectoryOriginUnknown -> null + } + if (discriminant != null) encodedObject["kind"] = JsonPrimitive(discriminant) + output.encodeJsonElement(JsonObject(encodedObject)) + } +} + @Serializable(with = AutomationTriggerSerializer::class) sealed interface AutomationTrigger diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt index 38b1b7f3c..31db5c2ff 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt @@ -34,11 +34,15 @@ import com.microsoft.agenthostprotocol.generated.ChangesetOperationTarget import com.microsoft.agenthostprotocol.generated.ChatSource import com.microsoft.agenthostprotocol.generated.CreateChatParams import com.microsoft.agenthostprotocol.generated.Customization +import com.microsoft.agenthostprotocol.generated.CreateSessionParams import com.microsoft.agenthostprotocol.generated.Implementation import com.microsoft.agenthostprotocol.generated.InitializeResult -import com.microsoft.agenthostprotocol.generated.SubscribeParams +import com.microsoft.agenthostprotocol.generated.InitializeParams import com.microsoft.agenthostprotocol.generated.SubscribeResult import com.microsoft.agenthostprotocol.generated.ReconnectResult +import com.microsoft.agenthostprotocol.generated.ListSessionsResult +import com.microsoft.agenthostprotocol.generated.SessionSummaryChangedParams +import com.microsoft.agenthostprotocol.generated.SubscribeParams import com.microsoft.agenthostprotocol.generated.ChannelFrameParams import com.microsoft.agenthostprotocol.generated.ChannelCreditParams import com.microsoft.agenthostprotocol.generated.ReconnectParams @@ -53,7 +57,9 @@ import com.microsoft.agenthostprotocol.generated.JsonRpcNotification import com.microsoft.agenthostprotocol.generated.JsonRpcRequest import com.microsoft.agenthostprotocol.generated.JsonRpcSuccessResponse import com.microsoft.agenthostprotocol.generated.PartialSessionSummary +import com.microsoft.agenthostprotocol.generated.ResolveSessionConfigParams import com.microsoft.agenthostprotocol.generated.SessionAddedParams +import com.microsoft.agenthostprotocol.generated.SessionConfigCompletionsParams import com.microsoft.agenthostprotocol.generated.ChatInputQuestion import com.microsoft.agenthostprotocol.generated.SessionStatus import com.microsoft.agenthostprotocol.generated.SessionSummary @@ -267,6 +273,12 @@ class RoundTripCorpusTest { "PartialSessionSummary" -> rt(PartialSessionSummary.serializer()) "Implementation" -> rt(Implementation.serializer()) "InitializeResult" -> rt(InitializeResult.serializer()) + "InitializeParams" -> rt(InitializeParams.serializer()) + "ListSessionsResult" -> rt(ListSessionsResult.serializer()) + "SessionSummaryChangedParams" -> rt(SessionSummaryChangedParams.serializer()) + "CreateSessionParams" -> rt(CreateSessionParams.serializer()) + "ResolveSessionConfigParams" -> rt(ResolveSessionConfigParams.serializer()) + "SessionConfigCompletionsParams" -> rt(SessionConfigCompletionsParams.serializer()) "CreateChatParams" -> rt(CreateChatParams.serializer()) "ChatSource" -> rt(ChatSource.serializer()) "Snapshot" -> rt(Snapshot.serializer()) diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index dc6fe0f7c..9274b1d80 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -24,6 +24,7 @@ use crate::state::{ SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolDefinition, ToolInput, ToolResultContent, Turn, UsageInfo, + WorkingDirectoryEntry, }; // ─── ActionType ────────────────────────────────────────────────────── @@ -1339,18 +1340,19 @@ pub struct SessionActiveClientRemovedAction { pub client_id: String, } -/// A working directory was added to the session's +/// A working directory was added or updated in the session's /// {@link SessionState.workingDirectories} set. /// -/// Membership semantics keyed by the directory URI: the reducer appends -/// `directory` when the set does not already contain it (creating the set if -/// absent) and is a no-op when it is already present. Only valid when the agent -/// advertises {@link AgentCapabilities.multipleWorkingDirectories}. +/// Upsert keyed by URI. A rich record replaces the existing entry in place; +/// a URI-only duplicate preserves known metadata. Only valid when the agent +/// advertises {@link AgentCapabilities.multipleWorkingDirectories}. Clients +/// MUST omit `repo` and use only local or absent `origin`; the host validates +/// and enriches the entry before accepting it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionWorkingDirectorySetAction { /// The working directory to grant the session's agent tool access to. - pub directory: Uri, + pub directory: WorkingDirectoryEntry, } /// A working directory was removed from the session's @@ -1382,6 +1384,8 @@ pub struct SessionWorkingDirectoryRemovedAction { /// for example, `[A, B, C]` with `B → C` becomes `[A, C]`. When it occurs /// before the target, it retains its earlier position and the target is removed; /// `[A, B, C]` with `C → A` becomes `[A, B]`. +/// A rich replacement supplies the complete entry; a URI-only replacement +/// preserves metadata already known for that URI. /// /// Only valid when the agent advertises /// {@link AgentCapabilities.multipleWorkingDirectories}. Replacing index `0` @@ -1389,13 +1393,14 @@ pub struct SessionWorkingDirectoryRemovedAction { /// {@link MultipleWorkingDirectoriesCapability.primaryReplacement}; clients /// MUST NOT target an immutable primary. The host MUST validate and apply its /// backend side effect before broadcasting an accepted action, or reject it. +/// Clients MUST omit `repo` and use only local or absent `origin`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionWorkingDirectoryReplacedAction { /// URI of the existing entry to replace. pub directory: Uri, - /// URI to place in the replaced entry's position. - pub replacement: Uri, + /// Complete entry to place in the replaced entry's position. + pub replacement: WorkingDirectoryEntry, } /// A working directory was added to this chat's @@ -1404,8 +1409,9 @@ pub struct SessionWorkingDirectoryReplacedAction { /// Membership semantics keyed by the directory URI: the reducer appends /// `directory` when the chat's subset does not already contain it (creating the /// subset if absent) and is a no-op when it is already present. `directory` MUST -/// be one of the owning session's {@link SessionState.workingDirectories}; a host -/// MUST reject a directory that is not. Only valid when the agent advertises +/// match a URI string or record's `uri` in the owning session's +/// {@link SessionState.workingDirectories}; a host MUST reject a directory that +/// does not. Only valid when the agent advertises /// {@link AgentCapabilities.multipleWorkingDirectories}. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index 6a5b53628..fe45126dc 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -266,6 +266,20 @@ impl<'de> serde::Deserialize<'de> for AuthenticateTargetKind { // ─── Command Payloads ───────────────────────────────────────────────── +/// Requested repository source, not a resolved working directory. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RepositorySource { + /// Credential-free repository source URI. + pub source: Uri, + /// Requested branch, tag, or commit. Omit to use the host's default revision. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision: Option, + /// Repository-relative selected folder; omit for the root. Hosts reject empty, absolute, or escaping paths. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subdirectory: Option, +} + /// Opens a TCP connection from the host to a destination on the host's network. /// /// The client chooses the channel URI. This command does not subscribe: after it @@ -415,6 +429,9 @@ pub struct InitializeResult { /// Suggested default directory for remote filesystem browsing #[serde(default, skip_serializing_if = "Option::is_none")] pub default_directory: Option, + /// Host repository preparation support; absent when unsupported. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository_preparation: Option, /// Characters that, when typed in a {@link Message} input, SHOULD cause /// the client to issue a `completions` request with /// {@link CompletionItemKind.UserMessage}. Typically includes characters like @@ -455,6 +472,24 @@ pub struct InitializeResult { pub multiple_chat_providers: Option, } +/// Host support for repository preparation at session creation. Presence +/// alone supports one repository at its default revision. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct RepositoryPreparationCapability { + /// When true, clients may supply {@link RepositorySource.revision}. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision: Option, + /// When true, clients may supply more than one repository. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub multiple_repositories: Option, +} + +/// Presence capability for {@link ClientCapabilities.workingDirectoryInfo}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkingDirectoryInfoCapability {} + /// Optional capabilities a client declares during `initialize`. /// /// Each field is a presence flag: an empty object `{}` means "supported", @@ -463,6 +498,10 @@ pub struct InitializeResult { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct ClientCapabilities { + /// Client accepts rich {@link WorkingDirectory} records as well as URI strings. + /// Hosts project records to URIs when absent and retain this choice on reconnect. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directory_info: Option, /// Client can render /// [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) — i.e. /// it can host the View sandbox, run the `ui/*` protocol against it, @@ -877,6 +916,10 @@ pub struct CreateSessionParams { /// after the session has started. #[serde(default, skip_serializing_if = "Option::is_none")] pub working_directories: Option>, + /// Repositories to prepare instead of an explicit `workingDirectories` list. + /// An empty list is equivalent to omission. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repositories: Option>, /// Agent-specific configuration values collected via `resolveSessionConfig`. /// Keys and values correspond to the schema returned by the server. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -985,9 +1028,9 @@ pub struct CreateChatParams { /// origin; any `responsePartId` there is provenance only, not a live range. #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, - /// Initial working-directory subset for this chat. Every entry MUST be - /// present in the owning session's `workingDirectories`; the server MUST - /// reject any entry that is not. When absent, the chat inherits the full + /// Initial working-directory URI subset for this chat. Every URI MUST match + /// a URI string or record's `uri` in the owning session's `workingDirectories`; + /// the server MUST reject any entry that does not. When absent, the chat inherits the full /// session set. Forked chats (those whose `source.kind` is `"fork"`) inherit /// the source chat's `workingDirectories`; this field is ignored for forks. /// @@ -1850,6 +1893,9 @@ pub struct ResolveSessionConfigParams { /// Working directory for the session #[serde(default, skip_serializing_if = "Option::is_none")] pub working_directory: Option, + /// Repositories used as configuration context; an empty list is equivalent to omission. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repositories: Option>, /// Current user-filled configuration values #[serde(default, skip_serializing_if = "Option::is_none")] pub config: Option, @@ -1885,6 +1931,9 @@ pub struct SessionConfigCompletionsParams { /// Working directory for the session #[serde(default, skip_serializing_if = "Option::is_none")] pub working_directory: Option, + /// Repositories used as configuration context; an empty list is equivalent to omission. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repositories: Option>, /// Current user-filled configuration values (provides context for the query) #[serde(default, skip_serializing_if = "Option::is_none")] pub config: Option, diff --git a/clients/rust/crates/ahp-types/src/notifications.rs b/clients/rust/crates/ahp-types/src/notifications.rs index 60e47bca1..71b362d5e 100644 --- a/clients/rust/crates/ahp-types/src/notifications.rs +++ b/clients/rust/crates/ahp-types/src/notifications.rs @@ -15,7 +15,7 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; use crate::state::{ AgentSelection, AnnotationsSummary, ChangesSummary, Changeset, FileEdit, ModelSelection, ProjectInfo, ProtectedResourceMetadata, SessionChatSummary, SessionOrigin, SessionStatus, - SessionSummary, Snapshot, + SessionSummary, Snapshot, WorkingDirectoryEntry, }; // ─── Enums ──────────────────────────────────────────────────────────── @@ -431,8 +431,10 @@ pub struct PartialSessionSummary { /// MAY restrict to a subset via /// {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a /// chat that sets none operates against this full set. + /// Entries are uniquely keyed by URI. Rich records require + /// {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_directories: Option>, + pub working_directories: Option>, /// Lightweight summary of this session's inline annotations channel /// (`ahp-annotations:/`). Surfaced so badge UI can render /// annotation / entry counts without subscribing. Absent when the session diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index dfbb59ec3..dbd74ee35 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -1431,6 +1431,45 @@ impl<'de> serde::Deserialize<'de> for SessionOriginKind { } } +/// How a working directory was prepared. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum WorkingDirectoryOriginKind { + Local, + Repo, + Worktree, + /// Unknown raw value from a newer protocol version, preserved verbatim. + Unknown(String), +} + +impl serde::Serialize for WorkingDirectoryOriginKind { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Local => serializer.serialize_str("local"), + Self::Repo => serializer.serialize_str("repo"), + Self::Worktree => serializer.serialize_str("worktree"), + Self::Unknown(value) => serializer.serialize_str(value), + } + } +} + +impl<'de> serde::Deserialize<'de> for WorkingDirectoryOriginKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = ::deserialize(deserializer)?; + Ok(match raw.as_str() { + "local" => Self::Local, + "repo" => Self::Repo, + "worktree" => Self::Worktree, + _ => Self::Unknown(raw), + }) + } +} + /// Operations the host currently permits for an automation. /// /// The list on {@link AutomationEntry.operations} is authoritative and may @@ -1960,6 +1999,35 @@ pub struct MultipleWorkingDirectoriesCapability { pub primary_replacement: Option, } +/// An actual working directory, uniquely keyed by `uri` within the session. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkingDirectory { + /// Actual selected directory, which may be a repository subdirectory. + pub uri: Uri, + /// Credential-free repository source association, not a checkout identity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo: Option, + /// Host-reported provenance; omission means unspecified. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalWorkingDirectoryOrigin {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RepoWorkingDirectoryOrigin {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeWorkingDirectoryOrigin { + /// Main worktree associated with the host-prepared worktree. + pub main_worktree: Uri, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionModelInfo { @@ -2189,13 +2257,14 @@ pub struct ChatState { pub provider: Option, /// The subset of the session's /// {@link SessionState.workingDirectories | `workingDirectories`} that this - /// chat's agent has tool access to. Every entry MUST be present in the owning - /// session's `workingDirectories`; servers MUST reject a + /// chat's agent has tool access to. Every URI MUST match a URI string or a + /// record's `uri` in the owning session's set; servers MUST reject a /// `chat/workingDirectorySet` action that violates this constraint. /// /// When absent, the chat inherits the full session set. When present but empty /// (not recommended), the chat has no working-directory tool access at all. /// + /// Directory metadata belongs to the session, not the chat. /// Dispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to /// update the subset on a running chat. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -2481,8 +2550,10 @@ pub struct SessionState { /// MAY restrict to a subset via /// {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a /// chat that sets none operates against this full set. + /// Entries are uniquely keyed by URI. Rich records require + /// {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_directories: Option>, + pub working_directories: Option>, /// Lightweight summary of this session's inline annotations channel /// (`ahp-annotations:/`). Surfaced so badge UI can render /// annotation / entry counts without subscribing. Absent when the session @@ -2821,8 +2892,10 @@ pub struct SessionSummary { /// MAY restrict to a subset via /// {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a /// chat that sets none operates against this full set. + /// Entries are uniquely keyed by URI. Rich records require + /// {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_directories: Option>, + pub working_directories: Option>, /// Lightweight summary of this session's inline annotations channel /// (`ahp-annotations:/`). Surfaced so badge UI can render /// annotation / entry counts without subscribing. Absent when the session @@ -6697,6 +6770,35 @@ pub enum ToolInput { ContentRef(ContentRef), } +/// A legacy URI or a complete working-directory record. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum WorkingDirectoryEntry { + Uri(Uri), + Directory(WorkingDirectory), +} + +impl WorkingDirectoryEntry { + pub fn uri(&self) -> &Uri { + match self { + Self::Uri(uri) => uri, + Self::Directory(directory) => &directory.uri, + } + } +} + +impl From for WorkingDirectoryEntry { + fn from(uri: Uri) -> Self { + Self::Uri(uri) + } +} + +impl From for WorkingDirectoryEntry { + fn from(directory: WorkingDirectory) -> Self { + Self::Directory(directory) + } +} + // ─── Discriminated Unions ───────────────────────────────────────────── /// How a chat came into existence. @@ -7077,6 +7179,22 @@ pub enum SessionOrigin { Unknown(serde_json::Value), } +/// Host-reported working-directory provenance. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum WorkingDirectoryOrigin { + #[serde(rename = "local")] + Local(LocalWorkingDirectoryOrigin), + #[serde(rename = "repo")] + Repo(RepoWorkingDirectoryOrigin), + #[serde(rename = "worktree")] + Worktree(WorktreeWorkingDirectoryOrigin), + /// 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), +} + /// Automatic trigger for an automation. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind")] diff --git a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs index 330f28625..0f5ea8906 100644 --- a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs +++ b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs @@ -30,14 +30,16 @@ use ahp_types::{ actions::{ActionEnvelope, StateAction}, commands::{ AuthenticateParams, ChangesetOperationTarget, ChatSource, CreateChatParams, - CreateTcpConnectionParams, Implementation, InitializeResult, ReconnectParams, - ReconnectResult, ResourceReadParams, ResourceReadResult, SubscribeParams, SubscribeResult, + CreateSessionParams, CreateTcpConnectionParams, Implementation, InitializeParams, + InitializeResult, ListSessionsResult, ReconnectParams, ReconnectResult, + ResolveSessionConfigParams, ResourceReadParams, ResourceReadResult, + SessionConfigCompletionsParams, SubscribeParams, SubscribeResult, }, common::StringOrMarkdown, messages::JsonRpcMessage, notifications::{ ChannelCreditParams, ChannelFrameParams, PartialSessionSummary, SessionAddedParams, - TcpDataParams, TcpEofParams, TcpResetParams, + SessionSummaryChangedParams, TcpDataParams, TcpEofParams, TcpResetParams, }, state::{ChatInputQuestion, Customization, SessionStatus, SessionSummary, Snapshot}, version::{PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS}, @@ -230,6 +232,12 @@ fn decode_and_reencode(file: &str, type_name: &str, input_json: &str) -> Result< "PartialSessionSummary" => round_trip!(PartialSessionSummary), "Implementation" => round_trip!(Implementation), "InitializeResult" => round_trip!(InitializeResult), + "InitializeParams" => round_trip!(InitializeParams), + "ListSessionsResult" => round_trip!(ListSessionsResult), + "SessionSummaryChangedParams" => round_trip!(SessionSummaryChangedParams), + "CreateSessionParams" => round_trip!(CreateSessionParams), + "ResolveSessionConfigParams" => round_trip!(ResolveSessionConfigParams), + "SessionConfigCompletionsParams" => round_trip!(SessionConfigCompletionsParams), "CreateChatParams" => round_trip!(CreateChatParams), "ChatSource" => round_trip!(ChatSource), "Snapshot" => round_trip!(Snapshot), diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 702224823..7536a982a 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -68,7 +68,7 @@ use ahp_types::state::{ ToolCallCancelledState, ToolCallCompletedState, ToolCallConfirmationReason, ToolCallContributor, ToolCallPendingConfirmationState, ToolCallPendingResultConfirmationState, ToolCallResponsePart, ToolCallRunningState, ToolCallState, ToolCallStatus, - ToolCallStreamingState, ToolInput, Turn, TurnState, + ToolCallStreamingState, ToolInput, Turn, TurnState, WorkingDirectoryEntry, }; use jiff::{SignedDuration, Timestamp}; @@ -881,17 +881,21 @@ pub fn apply_action_to_session(state: &mut SessionState, action: &StateAction) - } StateAction::SessionWorkingDirectorySet(a) => { let list = state.working_directories.get_or_insert_with(Vec::new); - if list.contains(&a.directory) { - return ReduceOutcome::NoOp; + if let Some(idx) = list.iter().position(|d| d.uri() == a.directory.uri()) { + if matches!(a.directory, WorkingDirectoryEntry::Uri(_)) { + return ReduceOutcome::NoOp; + } + list[idx] = a.directory.clone(); + } else { + list.push(a.directory.clone()); } - list.push(a.directory.clone()); ReduceOutcome::Applied } StateAction::SessionWorkingDirectoryRemoved(a) => { let Some(list) = state.working_directories.as_mut() else { return ReduceOutcome::NoOp; }; - let Some(idx) = list.iter().position(|d| *d == a.directory) else { + let Some(idx) = list.iter().position(|d| d.uri() == &a.directory) else { return ReduceOutcome::NoOp; }; list.remove(idx); @@ -901,25 +905,26 @@ pub fn apply_action_to_session(state: &mut SessionState, action: &StateAction) - let Some(list) = state.working_directories.as_mut() else { return ReduceOutcome::NoOp; }; - let Some(idx) = list.iter().position(|directory| directory == &a.directory) else { + let Some(idx) = list + .iter() + .position(|directory| directory.uri() == &a.directory) + else { return ReduceOutcome::NoOp; }; - if list[..idx] - .iter() - .any(|directory| directory == &a.replacement) - { + let replacement_idx = list.iter().position(|d| d.uri() == a.replacement.uri()); + let replacement = match (&a.replacement, replacement_idx) { + (WorkingDirectoryEntry::Uri(_), Some(i)) => list[i].clone(), + _ => a.replacement.clone(), + }; + if let Some(i) = replacement_idx.filter(|&i| i < idx) { + list[i] = replacement; list.remove(idx); - return ReduceOutcome::Applied; - } - let mut updated = Vec::with_capacity(list.len()); - for (i, directory) in list.iter().enumerate() { - if i == idx { - updated.push(a.replacement.clone()); - } else if directory != &a.replacement { - updated.push(directory.clone()); + } else { + list[idx] = replacement; + if let Some(i) = replacement_idx.filter(|&i| i > idx) { + list.remove(i); } } - *list = updated; ReduceOutcome::Applied } StateAction::SessionInputNeededSet(a) => { diff --git a/clients/rust/crates/ahp/tests/client_roundtrip.rs b/clients/rust/crates/ahp/tests/client_roundtrip.rs index 58482f447..ded101462 100644 --- a/clients/rust/crates/ahp/tests/client_roundtrip.rs +++ b/clients/rust/crates/ahp/tests/client_roundtrip.rs @@ -385,6 +385,7 @@ async fn session_config_completions_send_wrapper_targets_root_channel() { meta: None, provider: None, working_directory: None, + repositories: None, config: None, property: "baseBranch".into(), query: Some("ma".into()), diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index e97981c39..54d84cd8a 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -1514,11 +1514,11 @@ public struct SessionActiveClientRemovedAction: Codable, Sendable, Equatable { public struct SessionWorkingDirectorySetAction: Codable, Sendable, Equatable { public var type: ActionType /// The working directory to grant the session's agent tool access to. - public var directory: String + public var directory: WorkingDirectoryEntry public init( type: ActionType, - directory: String + directory: WorkingDirectoryEntry ) { self.type = type self.directory = directory @@ -1543,13 +1543,13 @@ public struct SessionWorkingDirectoryReplacedAction: Codable, Sendable, Equatabl public var type: ActionType /// URI of the existing entry to replace. public var directory: String - /// URI to place in the replaced entry's position. - public var replacement: String + /// Complete entry to place in the replaced entry's position. + public var replacement: WorkingDirectoryEntry public init( type: ActionType, directory: String, - replacement: String + replacement: WorkingDirectoryEntry ) { self.type = type self.directory = directory diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index 16dbcf143..14ab8149a 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -288,6 +288,25 @@ public struct SideChatSource: Codable, Sendable, Equatable { } } +public struct RepositorySource: Codable, Sendable, Equatable { + /// Credential-free repository source URI. + public var source: String + /// Requested branch, tag, or commit. Omit to use the host's default revision. + public var revision: String? + /// Repository-relative selected folder; omit for the root. Hosts reject empty, absolute, or escaping paths. + public var subdirectory: String? + + public init( + source: String, + revision: String? = nil, + subdirectory: String? = nil + ) { + self.source = source + self.revision = revision + self.subdirectory = subdirectory + } +} + /// Opens a TCP connection from the host to a destination on the host's network. /// /// The client chooses the channel URI. This command does not subscribe: after it @@ -459,6 +478,8 @@ public struct InitializeResult: Codable, Sendable, Equatable { public var environment: HostEnvironment? /// Suggested default directory for remote filesystem browsing public var defaultDirectory: String? + /// Host repository preparation support; absent when unsupported. + public var repositoryPreparation: RepositoryPreparationCapability? /// Characters that, when typed in a {@link Message} input, SHOULD cause /// the client to issue a `completions` request with /// {@link CompletionItemKind.UserMessage}. Typically includes characters like @@ -500,6 +521,7 @@ public struct InitializeResult: Codable, Sendable, Equatable { case snapshots case environment case defaultDirectory + case repositoryPreparation case completionTriggerCharacters case terminalCommandPrefix case telemetry @@ -516,6 +538,7 @@ public struct InitializeResult: Codable, Sendable, Equatable { snapshots: [Snapshot], environment: HostEnvironment? = nil, defaultDirectory: String? = nil, + repositoryPreparation: RepositoryPreparationCapability? = nil, completionTriggerCharacters: [String]? = nil, terminalCommandPrefix: String? = nil, telemetry: TelemetryCapabilities? = nil, @@ -530,6 +553,7 @@ public struct InitializeResult: Codable, Sendable, Equatable { self.snapshots = snapshots self.environment = environment self.defaultDirectory = defaultDirectory + self.repositoryPreparation = repositoryPreparation self.completionTriggerCharacters = completionTriggerCharacters self.terminalCommandPrefix = terminalCommandPrefix self.telemetry = telemetry @@ -561,6 +585,9 @@ public struct PingParams: Codable, Sendable, Equatable { } public struct ClientCapabilities: Codable, Sendable, Equatable { + /// Client accepts rich {@link WorkingDirectory} records as well as URI strings. + /// Hosts project records to URIs when absent and retain this choice on reconnect. + public var workingDirectoryInfo: WorkingDirectoryInfoCapability? /// Client can render /// [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) — i.e. /// it can host the View sandbox, run the `ui/*` protocol against it, @@ -575,8 +602,10 @@ public struct ClientCapabilities: Codable, Sendable, Equatable { public var mcpApps: [String: AnyCodable]? public init( + workingDirectoryInfo: WorkingDirectoryInfoCapability? = nil, mcpApps: [String: AnyCodable]? = nil ) { + self.workingDirectoryInfo = workingDirectoryInfo self.mcpApps = mcpApps } } @@ -616,6 +645,29 @@ public struct AutomationCapabilities: Codable, Sendable, Equatable { } } +public struct RepositoryPreparationCapability: Codable, Sendable, Equatable { + /// When true, clients may supply {@link RepositorySource.revision}. + public var revision: Bool? + /// When true, clients may supply more than one repository. + public var multipleRepositories: Bool? + + public init( + revision: Bool? = nil, + multipleRepositories: Bool? = nil + ) { + self.revision = revision + self.multipleRepositories = multipleRepositories + } +} + +public struct WorkingDirectoryInfoCapability: Codable, Sendable, Equatable { + + public init( + + ) { + } +} + public struct AutomationCreateCapability: Codable, Sendable, Equatable { public init( @@ -985,6 +1037,9 @@ public struct CreateSessionParams: Codable, Sendable, Equatable { /// and ignores the rest. Dispatch working-directory actions to change the set /// after the session has started. public var workingDirectories: [String]? + /// Repositories to prepare instead of an explicit `workingDirectories` list. + /// An empty list is equivalent to omission. + public var repositories: [RepositorySource]? /// Agent-specific configuration values collected via `resolveSessionConfig`. /// Keys and values correspond to the schema returned by the server. public var config: [String: AnyCodable]? @@ -1012,6 +1067,7 @@ public struct CreateSessionParams: Codable, Sendable, Equatable { case meta = "_meta" case provider case workingDirectories + case repositories case config case activeClient case progressToken @@ -1022,6 +1078,7 @@ public struct CreateSessionParams: Codable, Sendable, Equatable { meta: [String: AnyCodable]? = nil, provider: String? = nil, workingDirectories: [String]? = nil, + repositories: [RepositorySource]? = nil, config: [String: AnyCodable]? = nil, activeClient: SessionActiveClient? = nil, progressToken: String? = nil @@ -1030,6 +1087,7 @@ public struct CreateSessionParams: Codable, Sendable, Equatable { self.meta = meta self.provider = provider self.workingDirectories = workingDirectories + self.repositories = repositories self.config = config self.activeClient = activeClient self.progressToken = progressToken @@ -1082,9 +1140,9 @@ public struct CreateChatParams: Codable, Sendable, Equatable { /// also snapshots and preserves that exact selected text in the created chat's /// origin; any `responsePartId` there is provenance only, not a live range. public var source: ChatSource? - /// Initial working-directory subset for this chat. Every entry MUST be - /// present in the owning session's `workingDirectories`; the server MUST - /// reject any entry that is not. When absent, the chat inherits the full + /// Initial working-directory URI subset for this chat. Every URI MUST match + /// a URI string or record's `uri` in the owning session's `workingDirectories`; + /// the server MUST reject any entry that does not. When absent, the chat inherits the full /// session set. Forked chats (those whose `source.kind` is `"fork"`) inherit /// the source chat's `workingDirectories`; this field is ignored for forks. /// @@ -2086,6 +2144,8 @@ public struct ResolveSessionConfigParams: Codable, Sendable, Equatable { public var provider: String? /// Working directory for the session public var workingDirectory: String? + /// Repositories used as configuration context; an empty list is equivalent to omission. + public var repositories: [RepositorySource]? /// Current user-filled configuration values public var config: [String: AnyCodable]? @@ -2094,6 +2154,7 @@ public struct ResolveSessionConfigParams: Codable, Sendable, Equatable { case meta = "_meta" case provider case workingDirectory + case repositories case config } @@ -2102,12 +2163,14 @@ public struct ResolveSessionConfigParams: Codable, Sendable, Equatable { meta: [String: AnyCodable]? = nil, provider: String? = nil, workingDirectory: String? = nil, + repositories: [RepositorySource]? = nil, config: [String: AnyCodable]? = nil ) { self.channel = channel self.meta = meta self.provider = provider self.workingDirectory = workingDirectory + self.repositories = repositories self.config = config } } @@ -2255,6 +2318,8 @@ public struct SessionConfigCompletionsParams: Codable, Sendable, Equatable { public var provider: String? /// Working directory for the session public var workingDirectory: String? + /// Repositories used as configuration context; an empty list is equivalent to omission. + public var repositories: [RepositorySource]? /// Current user-filled configuration values (provides context for the query) public var config: [String: AnyCodable]? /// Property id from the schema to query values for @@ -2267,6 +2332,7 @@ public struct SessionConfigCompletionsParams: Codable, Sendable, Equatable { case meta = "_meta" case provider case workingDirectory + case repositories case config case property case query @@ -2277,6 +2343,7 @@ public struct SessionConfigCompletionsParams: Codable, Sendable, Equatable { meta: [String: AnyCodable]? = nil, provider: String? = nil, workingDirectory: String? = nil, + repositories: [RepositorySource]? = nil, config: [String: AnyCodable]? = nil, property: String, query: String? = nil @@ -2285,6 +2352,7 @@ public struct SessionConfigCompletionsParams: Codable, Sendable, Equatable { self.meta = meta self.provider = provider self.workingDirectory = workingDirectory + self.repositories = repositories self.config = config self.property = property self.query = query diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift index 1fc3b90f6..f82c4117a 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift @@ -389,7 +389,9 @@ public struct PartialSessionSummary: Codable, Sendable, Equatable { /// MAY restrict to a subset via /// {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a /// chat that sets none operates against this full set. - public var workingDirectories: [String]? + /// Entries are uniquely keyed by URI. Rich records require + /// {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. + public var workingDirectories: [WorkingDirectoryEntry]? /// Lightweight summary of this session's inline annotations channel /// (`ahp-annotations:/`). Surfaced so badge UI can render /// annotation / entry counts without subscribing. Absent when the session @@ -441,7 +443,7 @@ public struct PartialSessionSummary: Codable, Sendable, Equatable { activity: String? = nil, origin: SessionOrigin? = nil, project: ProjectInfo? = nil, - workingDirectories: [String]? = nil, + workingDirectories: [WorkingDirectoryEntry]? = nil, annotations: AnnotationsSummary? = nil, resource: String? = nil, createdAt: String? = nil, diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index a59c63da5..4d0e29874 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -1058,6 +1058,36 @@ public enum SessionOriginKind: Codable, Sendable, Equatable { } } +/// How a working directory was prepared. +public enum WorkingDirectoryOriginKind: Codable, Sendable, Equatable { + case local + case repo + case worktree + /// 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 "local": self = .local + case "repo": self = .repo + case "worktree": self = .worktree + default: self = .unknown(raw) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .local: try container.encode("local") + case .repo: try container.encode("repo") + case .worktree: try container.encode("worktree") + case .unknown(let raw): try container.encode(raw) + } + } +} + /// Operations the host currently permits for an automation. /// /// The list on {@link AutomationEntry.operations} is authoritative and may @@ -1646,6 +1676,59 @@ public struct MultipleWorkingDirectoriesCapability: Codable, Sendable, Equatable } } +public struct WorkingDirectory: Codable, Sendable, Equatable { + /// Actual selected directory, which may be a repository subdirectory. + public var uri: String + /// Credential-free repository source association, not a checkout identity. + public var repo: String? + /// Host-reported provenance; omission means unspecified. + public var origin: WorkingDirectoryOrigin? + + public init( + uri: String, + repo: String? = nil, + origin: WorkingDirectoryOrigin? = nil + ) { + self.uri = uri + self.repo = repo + self.origin = origin + } +} + +public struct LocalWorkingDirectoryOrigin: Codable, Sendable, Equatable { + public var kind: WorkingDirectoryOriginKind + + public init( + kind: WorkingDirectoryOriginKind + ) { + self.kind = kind + } +} + +public struct RepoWorkingDirectoryOrigin: Codable, Sendable, Equatable { + public var kind: WorkingDirectoryOriginKind + + public init( + kind: WorkingDirectoryOriginKind + ) { + self.kind = kind + } +} + +public struct WorktreeWorkingDirectoryOrigin: Codable, Sendable, Equatable { + public var kind: WorkingDirectoryOriginKind + /// Main worktree associated with the host-prepared worktree. + public var mainWorktree: String + + public init( + kind: WorkingDirectoryOriginKind, + mainWorktree: String + ) { + self.kind = kind + self.mainWorktree = mainWorktree + } +} + public struct SessionModelInfo: Codable, Sendable, Equatable { /// Model identifier public var id: String @@ -1969,13 +2052,14 @@ public struct ChatState: Codable, Sendable, Equatable { public var provider: String? /// The subset of the session's /// {@link SessionState.workingDirectories | `workingDirectories`} that this - /// chat's agent has tool access to. Every entry MUST be present in the owning - /// session's `workingDirectories`; servers MUST reject a + /// chat's agent has tool access to. Every URI MUST match a URI string or a + /// record's `uri` in the owning session's set; servers MUST reject a /// `chat/workingDirectorySet` action that violates this constraint. /// /// When absent, the chat inherits the full session set. When present but empty /// (not recommended), the chat has no working-directory tool access at all. /// + /// Directory metadata belongs to the session, not the chat. /// Dispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to /// update the subset on a running chat. public var workingDirectories: [String]? @@ -2221,7 +2305,9 @@ public struct SessionState: Codable, Sendable, Equatable { /// MAY restrict to a subset via /// {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a /// chat that sets none operates against this full set. - public var workingDirectories: [String]? + /// Entries are uniquely keyed by URI. Rich records require + /// {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. + public var workingDirectories: [WorkingDirectoryEntry]? /// Lightweight summary of this session's inline annotations channel /// (`ahp-annotations:/`). Surfaced so badge UI can render /// annotation / entry counts without subscribing. Absent when the session @@ -2335,7 +2421,7 @@ public struct SessionState: Codable, Sendable, Equatable { activity: String? = nil, origin: SessionOrigin? = nil, project: ProjectInfo? = nil, - workingDirectories: [String]? = nil, + workingDirectories: [WorkingDirectoryEntry]? = nil, annotations: AnnotationsSummary? = nil, lifecycle: SessionLifecycle, creationError: ErrorInfo? = nil, @@ -2647,7 +2733,9 @@ public struct SessionSummary: Codable, Sendable, Equatable { /// MAY restrict to a subset via /// {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a /// chat that sets none operates against this full set. - public var workingDirectories: [String]? + /// Entries are uniquely keyed by URI. Rich records require + /// {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. + public var workingDirectories: [WorkingDirectoryEntry]? /// Lightweight summary of this session's inline annotations channel /// (`ahp-annotations:/`). Surfaced so badge UI can render /// annotation / entry counts without subscribing. Absent when the session @@ -2699,7 +2787,7 @@ public struct SessionSummary: Codable, Sendable, Equatable { activity: String? = nil, origin: SessionOrigin? = nil, project: ProjectInfo? = nil, - workingDirectories: [String]? = nil, + workingDirectories: [WorkingDirectoryEntry]? = nil, annotations: AnnotationsSummary? = nil, resource: String, createdAt: String, @@ -8876,6 +8964,36 @@ public enum ToolInput: Codable, Sendable, Equatable { } } +/// A legacy URI or a complete working-directory record. +public enum WorkingDirectoryEntry: Codable, Sendable { + case uri(String) + case directory(WorkingDirectory) + + public var uri: String { + switch self { + case .uri(let value): return value + case .directory(let value): return value.uri + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let uri = try? container.decode(String.self) { + self = .uri(uri) + } else { + self = .directory(try container.decode(WorkingDirectory.self)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .uri(let value): try container.encode(value) + case .directory(let value): try container.encode(value) + } + } +} + // MARK: - Discriminated Unions public struct ChatOriginUser: Codable, Sendable, Equatable { @@ -9740,6 +9858,52 @@ public enum SessionOrigin: Codable, Sendable, Equatable { } } +public enum WorkingDirectoryOrigin: Codable, Sendable, Equatable { + case local(LocalWorkingDirectoryOrigin) + case repo(RepoWorkingDirectoryOrigin) + case worktree(WorktreeWorkingDirectoryOrigin) + /// 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 "local": + self = .local(try LocalWorkingDirectoryOrigin(from: decoder)) + case "repo": + self = .repo(try RepoWorkingDirectoryOrigin(from: decoder)) + case "worktree": + self = .worktree(try WorktreeWorkingDirectoryOrigin(from: decoder)) + default: + self = .unknown(try AnyCodable(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .local(var value): + value.kind = .local + try value.encode(to: encoder) + case .repo(var value): + value.kind = .repo + try value.encode(to: encoder) + case .worktree(var value): + value.kind = .worktree + try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) + } + } +} + public enum AutomationTrigger: Codable, Sendable, Equatable { case schedule(AutomationScheduleTrigger) case event(AutomationEventTrigger) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index dca8a0725..f22a83fa0 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -881,31 +881,43 @@ public func sessionReducer(state: SessionState, action: StateAction) -> SessionS return next case .sessionWorkingDirectorySet(let a): - if (state.workingDirectories ?? []).contains(a.directory) { return state } var next = state - next.workingDirectories = (next.workingDirectories ?? []) + [a.directory] + var directories = state.workingDirectories ?? [] + if let idx = directories.firstIndex(where: { $0.uri == a.directory.uri }) { + guard case .directory = a.directory else { return state } + directories[idx] = a.directory + } else { + directories.append(a.directory) + } + next.workingDirectories = directories return next case .sessionWorkingDirectoryRemoved(let a): - guard let idx = state.workingDirectories?.firstIndex(of: a.directory) else { return state } + guard let idx = state.workingDirectories?.firstIndex(where: { $0.uri == a.directory }) else { return state } var next = state next.workingDirectories?.remove(at: idx) return next case .sessionWorkingDirectoryReplaced(let a): guard let directories = state.workingDirectories, - let idx = directories.firstIndex(of: a.directory) else { return state } + let idx = directories.firstIndex(where: { $0.uri == a.directory }) else { return state } var next = state - if let replacementIdx = directories.firstIndex(of: a.replacement), replacementIdx < idx { - next.workingDirectories = directories.enumerated().compactMap { index, directory in - index == idx ? nil : directory - } + let replacementIdx = directories.firstIndex(where: { $0.uri == a.replacement.uri }) + var replacement = a.replacement + if case .uri = replacement, let replacementIdx { + replacement = directories[replacementIdx] + } + var updated = directories + if let replacementIdx, replacementIdx < idx { + updated[replacementIdx] = replacement + updated.remove(at: idx) } else { - next.workingDirectories = directories.enumerated().compactMap { index, directory in - if index == idx { return a.replacement } - return directory == a.replacement ? nil : directory + updated[idx] = replacement + if let replacementIdx, replacementIdx > idx { + updated.remove(at: replacementIdx) } } + next.workingDirectories = updated return next case .sessionInputNeededSet(let a): diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift index 225d8d0ea..86401e561 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift @@ -188,6 +188,18 @@ final class TypesRoundTripFixtureTests: XCTestCase { return try reencode(dec.decode(Implementation.self, from: inputData)) case "InitializeResult": return try reencode(dec.decode(InitializeResult.self, from: inputData)) + case "InitializeParams": + return try reencode(dec.decode(InitializeParams.self, from: inputData)) + case "ListSessionsResult": + return try reencode(dec.decode(ListSessionsResult.self, from: inputData)) + case "SessionSummaryChangedParams": + return try reencode(dec.decode(SessionSummaryChangedParams.self, from: inputData)) + case "CreateSessionParams": + return try reencode(dec.decode(CreateSessionParams.self, from: inputData)) + case "ResolveSessionConfigParams": + return try reencode(dec.decode(ResolveSessionConfigParams.self, from: inputData)) + case "SessionConfigCompletionsParams": + return try reencode(dec.decode(SessionConfigCompletionsParams.self, from: inputData)) case "CreateChatParams": return try reencode(dec.decode(CreateChatParams.self, from: inputData)) case "ChatSource": diff --git a/clients/typescript/test/client.test.ts b/clients/typescript/test/client.test.ts index 8b4611ea7..4c8d6cb73 100644 --- a/clients/typescript/test/client.test.ts +++ b/clients/typescript/test/client.test.ts @@ -42,7 +42,14 @@ import type { import { JsonRpcErrorCodes } from '../src/types/common/errors.js'; import { AutomationOperation, type AutomationEntry } from '../src/types/channels-automation/state.js'; import { MessageKind } from '../src/types/channels-chat/state.js'; -import { SessionLifecycle, SessionStatus } from '../src/types/channels-session/state.js'; +import { + SessionLifecycle, + SessionStatus, + WorkingDirectoryOriginKind, + type SessionConfigSchema, + type SessionState, + type WorkingDirectory, +} from '../src/types/index.js'; import type { CanvasState } from '../src/types/channels-canvas/state.js'; import type { Snapshot } from '../src/types/common/state.js'; @@ -111,6 +118,180 @@ test('initialize round-trip', async () => { await client.shutdown(); }); +for (const repositoryPreparation of [ + undefined, {}, { revision: true }, { multipleRepositories: false }, + { multipleRepositories: true }, { revision: true, multipleRepositories: true }, +]) { + test(`initialize preserves host repository capability ${JSON.stringify(repositoryPreparation)}`, async t => { + const [c, s] = InMemoryTransport.pair(); + const client = new AhpClient(c); + t.after(() => client.shutdown()); + client.connect(); + + const initialization = client.initialize({ clientId: 'repository-client', protocolVersions: ['0.9.0'] }); + const request = await readRequest(s); + const result: InitializeResult = { + protocolVersion: '0.9.0', serverSeq: 0, snapshots: [], + ...(repositoryPreparation === undefined ? {} : { repositoryPreparation }), + }; + reply(s, request.id, result); + assert.deepEqual(await initialization, result); + }); +} + +const repositorySource = 'https://example.org/team/project.git'; +for (const { name, repositories } of [ + { name: 'default revision', repositories: [{ source: repositorySource }] }, + { name: 'explicit revision', repositories: [{ source: repositorySource, revision: 'refs/tags/v1.2.3' }] }, + { name: 'same source at two revisions', repositories: [ + { source: repositorySource, revision: 'main' }, + { source: repositorySource, revision: 'feature' }, + ] }, +]) { + test(`typed requests round-trip repository lists (${name}) outside config`, async t => { + const [c, s] = InMemoryTransport.pair(); + const client = new AhpClient(c); + t.after(() => client.shutdown()); + client.connect(); + + const schema: SessionConfigSchema = { + type: 'object', + properties: { + mode: { type: 'string', title: 'Mode', default: 'review' }, + }, + }; + const discovery = client.request('resolveSessionConfig', { channel: ROOT }); + const discoveryRequest = await readRequest(s); + assert.equal(discoveryRequest.method, 'resolveSessionConfig'); + assert.deepEqual(discoveryRequest.params, { channel: ROOT }); + reply(s, discoveryRequest.id, { schema, values: { mode: 'review' } }); + + const discovered = await discovery; + assert.deepEqual(discovered.schema, schema); + const context = { repositories, workingDirectory: 'file:///work/context' }; + const config = discovered.values; + const resolution = client.request('resolveSessionConfig', { channel: ROOT, ...context, config }); + const resolveRequest = await readRequest(s); + assert.equal(resolveRequest.method, 'resolveSessionConfig'); + assert.deepEqual(resolveRequest.params, { channel: ROOT, ...context, config }); + reply(s, resolveRequest.id, { schema, values: config }); + const resolved = await resolution; + assert.deepEqual(resolved.values, config); + + const completions = client.request('sessionConfigCompletions', { channel: ROOT, ...context, config: resolved.values, property: 'mode' }); + const completionRequest = await readRequest(s); + assert.deepEqual(completionRequest.params, { channel: ROOT, ...context, config: resolved.values, property: 'mode' }); + reply(s, completionRequest.id, { items: [] }); + await completions; + + const params = { channel: 'ahp-session:/repository-test', repositories, config: resolved.values }; + const creation = client.request('createSession', params); + const createRequest = await readRequest(s); + assert.equal(createRequest.method, 'createSession'); + assert.deepEqual(createRequest.params, params); + reply(s, createRequest.id, null); + assert.equal(await creation, null); + }); +} + +for (const method of ['resolveSessionConfig', 'sessionConfigCompletions', 'createSession'] as const) { + test(`${method} surfaces host rejection of repository intent`, async t => { + const [c, s] = InMemoryTransport.pair(); + const client = new AhpClient(c); + t.after(() => client.shutdown()); + client.connect(); + + const channel = method === 'createSession' ? 'ahp-session:/repository-test' : ROOT; + const params = { + channel, + repositories: [{ source: repositorySource, revision: 'unsupported' }], + ...(method === 'sessionConfigCompletions' ? { property: 'mode' } : {}), + }; + const request = method === 'sessionConfigCompletions' + ? client.request(method, { ...params, property: 'mode' }) + : client.request(method, params); + const rejected = assert.rejects(request, new RpcError(JsonRpcErrorCodes.InvalidParams, 'Unsupported repository revision')); + const sent = await readRequest(s); + assert.deepEqual({ method: sent.method, params: sent.params }, { method, params }); + replyError(s, sent.id, JsonRpcErrorCodes.InvalidParams, 'Unsupported repository revision'); + await rejected; + }); +} + +for (const failed of [false, true]) { + test(`session state retains resolved directory metadata after creation ${failed ? 'fails' : 'succeeds'}`, () => { + const resource = 'ahp-session:/repository-test'; + const workingDirectories: WorkingDirectory[] = [ + { uri: 'file:///work/project', repo: repositorySource, origin: { kind: WorkingDirectoryOriginKind.Repo } }, + { + uri: 'file:///work/project-worktree/packages/api', + repo: repositorySource, + origin: { kind: WorkingDirectoryOriginKind.Worktree, mainWorktree: 'file:///work/project' }, + }, + ]; + const initial: SessionState = { + provider: 'example', + title: 'Repository session', + status: SessionStatus.Idle, + lifecycle: SessionLifecycle.Creating, + activeClients: [], + chats: [], + workingDirectories: [], + config: { + schema: { type: 'object', properties: { mode: { type: 'string', title: 'Mode' } } }, + values: { mode: 'review' }, + }, + }; + const mirror = new AhpStateMirror(); + mirror.applySnapshot({ resource, state: initial, fromSeq: 0 }); + mirror.apply({ + channel: resource, + serverSeq: 1, + origin: undefined, + action: { type: ActionType.SessionWorkingDirectorySet, directory: workingDirectories[0] }, + }); + mirror.apply({ + channel: resource, + serverSeq: 2, + origin: undefined, + action: { type: ActionType.SessionWorkingDirectorySet, directory: workingDirectories[1] }, + }); + const preparing = mirror.getSession(resource); + assert.ok(preparing); + assert.equal(preparing.lifecycle, SessionLifecycle.Creating); + assert.deepEqual(preparing.config, initial.config); + assert.deepEqual(preparing.workingDirectories, workingDirectories); + + const joining = new AhpStateMirror(); + joining.applySnapshot({ resource, state: preparing, fromSeq: 2 }); + assert.deepEqual(joining.getSession(resource), preparing); + + const completion: ActionEnvelope = { + channel: resource, + serverSeq: 3, + origin: undefined, + action: failed + ? { type: ActionType.SessionCreationFailed, error: { errorType: 'preparationFailed', message: 'Preparation failed' } } + : { type: ActionType.SessionReady }, + }; + mirror.apply(completion); + joining.apply(completion); + const completed = mirror.getSession(resource); + assert.ok(completed); + assert.equal(completed.lifecycle, failed ? SessionLifecycle.Failed : SessionLifecycle.Ready); + assert.deepEqual(completed.config, initial.config); + assert.deepEqual(completed.workingDirectories, workingDirectories); + assert.deepEqual(joining.getSession(resource), completed); + if (failed) { + assert.deepEqual(completed.creationError, { errorType: 'preparationFailed', message: 'Preparation failed' }); + } + + const reconnected = new AhpStateMirror(); + reconnected.applySnapshot({ resource, state: completed, fromSeq: 3 }); + assert.deepEqual(reconnected.getSession(resource), completed); + }); +} + test('subscribe attaches before sending the request and fans out an action', async () => { const [c, s] = InMemoryTransport.pair(); const client = new AhpClient(c); diff --git a/clients/typescript/test/types-round-trip.test.ts b/clients/typescript/test/types-round-trip.test.ts index 086c8bf6a..5c2c602a9 100644 --- a/clients/typescript/test/types-round-trip.test.ts +++ b/clients/typescript/test/types-round-trip.test.ts @@ -58,15 +58,17 @@ import type { Customization, SessionSummary, } from '../src/types/channels-session/state.js'; -import type { SessionAddedParams } from '../src/types/channels-root/notifications.js'; +import type { SessionAddedParams, SessionSummaryChangedParams } from '../src/types/channels-root/notifications.js'; import type { - Implementation, InitializeResult, SubscribeParams, SubscribeResult, ReconnectParams, ReconnectResult, + Implementation, InitializeParams, InitializeResult, SubscribeParams, SubscribeResult, ReconnectParams, ReconnectResult, ResourceReadParams, ResourceReadResult, AuthenticateParams, } from '../src/types/common/commands.js'; import type { ChannelFrameParams, ChannelCreditParams } from '../src/types/common/notifications.js'; import type { TcpDataParams, TcpEofParams, TcpResetParams } from '../src/types/channels-tcp/notifications.js'; import type { CreateTcpConnectionParams } from '../src/types/channels-tcp/commands.js'; import type { ChatSource, CreateChatParams } from '../src/types/channels-chat/commands.js'; +import type { CreateSessionParams } from '../src/types/channels-session/commands.js'; +import type { ListSessionsResult, ResolveSessionConfigParams, SessionConfigCompletionsParams } from '../src/types/channels-root/commands.js'; // ─── Fixture directory ─────────────────────────────────────────────────────── @@ -249,6 +251,12 @@ function bindToType(file: string, type: string, parsed: unknown): void { case 'PartialSessionSummary': void (parsed as Partial); break; case 'Implementation': void (parsed as Implementation); break; case 'InitializeResult': void (parsed as InitializeResult); break; + case 'InitializeParams': void (parsed as InitializeParams); break; + case 'ListSessionsResult': void (parsed as ListSessionsResult); break; + case 'SessionSummaryChangedParams': void (parsed as SessionSummaryChangedParams); break; + case 'CreateSessionParams': void (parsed as CreateSessionParams); break; + case 'ResolveSessionConfigParams': void (parsed as ResolveSessionConfigParams); break; + case 'SessionConfigCompletionsParams': void (parsed as SessionConfigCompletionsParams); break; case 'CreateChatParams': void (parsed as CreateChatParams); break; case 'ChatSource': void (parsed as ChatSource); break; case 'Snapshot': void (parsed as Snapshot); break; diff --git a/clients/typescript/test/working-directories.test.ts b/clients/typescript/test/working-directories.test.ts new file mode 100644 index 000000000..9773973b3 --- /dev/null +++ b/clients/typescript/test/working-directories.test.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { sessionReducer } from '../src/types/reducers.js'; +import { ActionType } from '../src/types/common/actions.js'; +import type { SessionAction } from '../src/types/action-origin.generated.js'; +import type { SessionState } from '../src/types/channels-session/state.js'; + +interface Fixture { + reducer: string; + initial: SessionState; + actions: SessionAction[]; + expected: SessionState; +} + +const directoryActions = new Set([ + ActionType.SessionWorkingDirectorySet, + ActionType.SessionWorkingDirectoryRemoved, + ActionType.SessionWorkingDirectoryReplaced, +]); +const fixtureDirectory = new URL('../../../types/test-cases/reducers/', import.meta.url); + +for (const file of readdirSync(fixtureDirectory).filter(file => file.endsWith('.json')).sort()) { + const fixture = JSON.parse(readFileSync(new URL(file, fixtureDirectory), 'utf8')) as Fixture; + if (fixture.reducer !== 'session' || !fixture.actions.some(action => directoryActions.has(action.type))) { + continue; + } + test(`SDK working-directory fixture: ${file}`, () => { + const actual = fixture.actions.reduce(sessionReducer, fixture.initial); + assert.deepEqual(actual, fixture.expected, file); + }); +} diff --git a/docs/.changes/20260915-repository-session-config.json b/docs/.changes/20260915-repository-session-config.json new file mode 100644 index 000000000..be53ebdfa --- /dev/null +++ b/docs/.changes/20260915-repository-session-config.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "Typed repository preparation inputs with subdirectory selection, host capability discovery, and opt-in URI-keyed working-directory provenance across session state and actions." +} diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index 8a4013c6e..10db90c27 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -76,7 +76,7 @@ SessionState { status: number // SessionStatus bitset activity?: string project?: ProjectInfo - workingDirectories?: URI[] // equal-peer working directories + workingDirectories?: (URI | WorkingDirectory)[] // URI-keyed working directories annotations?: AnnotationsSummary lifecycle: 'creating' | 'ready' | 'failed' @@ -110,7 +110,7 @@ SessionSummary { createdAt: string // ISO 8601, e.g. "2025-03-10T18:42:03.123Z" modifiedAt: string // ISO 8601 project?: ProjectInfo - workingDirectories?: URI[] // equal-peer working directories + workingDirectories?: (URI | WorkingDirectory)[] // URI-keyed working directories annotations?: AnnotationsSummary changes?: ChangesSummary chats?: SessionChatSummary[] // compact list presentation, including per-chat status @@ -713,9 +713,14 @@ directories of the source session. The directory set is state (`SessionState.workingDirectories`), so clients mutate it by **dispatching actions**, not by calling commands: +Entries are keyed by URI, whether represented as URI strings or rich +`WorkingDirectory` records. Rich records require the client's +`workingDirectoryInfo` capability; see [repository-backed creation and +compatibility](/specification/session-channel#repository-backed-creation). + | Action | Effect | | --- | --- | -| `session/workingDirectorySet` | Adds `directory` to the set (creating it if absent). A no-op when the directory is already present. | +| `session/workingDirectorySet` | Upserts `directory` by URI, appending when absent and replacing a rich same-URI entry in place. A URI-only duplicate preserves known metadata. | | `session/workingDirectoryRemoved` | Removes `directory` from the set. A no-op when it is not present. There is no atomic backend "remove one" primitive — the host reconfigures its agent to the reduced set. A host MAY decline to apply the removal (e.g. the immutable primary at index 0), leaving the set unchanged. | | `session/workingDirectoryReplaced` | Targeted compare-and-swap replacement of any entry that deduplicates `replacement`, preserving every other directory's relative order. An existing replacement after the target moves to the target's position (`[A, B, C]`, `B -> C` becomes `[A, C]`); an earlier replacement stays in place and the target is removed (`[A, B, C]`, `C -> A` becomes `[A, B]`). A no-op when `directory` is absent. Replacing index `0` additionally requires `primaryReplacement`. | diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index 8bed3089c..591d6db12 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -68,7 +68,7 @@ Clients MAY periodically sync their local input state into the draft by dispatch ### Per-chat working directory -`ChatState.workingDirectories` (and its mirror on [`ChatSummary`](/reference/chat#chatsummary)) is **optional**. When absent, the chat inherits the session's full [`workingDirectories`](/reference/session#sessionsummary) set; when present it MUST be a subset of that set. Hosts MAY set a per-chat subset to give individual chats their own filesystem context — for example, allocating a separate git worktree per chat so multiple chats in the same session can make independent edits that the orchestrating chat later merges back. +`ChatState.workingDirectories` (and its mirror on [`ChatSummary`](/reference/chat#chatsummary)) is an **optional URI array**. When absent, the chat inherits the session's full [`workingDirectories`](/reference/session#sessionsummary) set. When present, every URI MUST match a session URI string or a record's `uri`; directory metadata remains on the session. Hosts MAY set a per-chat subset to give individual chats their own filesystem context — for example, allocating a separate git worktree per chat so multiple chats in the same session can make independent edits that the orchestrating chat later merges back. ### Per-chat changesets diff --git a/docs/specification/session-channel.md b/docs/specification/session-channel.md index 133121f19..96e3272b1 100644 --- a/docs/specification/session-channel.md +++ b/docs/specification/session-channel.md @@ -35,6 +35,117 @@ Subscribers receive a [`SessionState`](/reference/session#sessionstate) snapshot [`createSession`](/reference/session#createsession) is a JSON-RPC request. The client picks the URI; the server allocates session state and begins backend initialisation. If the URI is already in use the server returns `SessionAlreadyExists` (`-32003`). +#### Repository-backed creation + +A host can prepare repositories for a new session. The client supplies sources; the host owns authorization, credentials, and preparation. This does not introduce a clone command or require a particular repository or worktree backend. + +##### Capability and field constraints + +The **host**, not an individual agent, opts in through [`InitializeResult.repositoryPreparation`](/reference/common#initialize), a [`RepositoryPreparationCapability`](/reference/common#repositorypreparationcapability): + +| Capability | Meaning | +|---|---| +| Absent | Repository preparation and repository context in configuration queries are unsupported. | +| `{}` | Supports one repository at its default revision. | +| `revision: true` | Also supports an explicit branch, tag, or commit. | +| `multipleRepositories: true` | Also supports a list containing more than one repository. Absent or `false` means exactly one entry when a list is present. | + +The optional typed `repositories: RepositorySource[]` field is shared by [`CreateSessionParams`](/reference/session#createsession), [`ResolveSessionConfigParams`](/reference/root#resolvesessionconfig), and [`SessionConfigCompletionsParams`](/reference/root#sessionconfigcompletions). Each [`RepositorySource`](/reference/session#repositorysource) contains: + +| Field | Meaning | +|---|---| +| `source` | Required credential-free repository URI identifying the requested source. | +| `revision` | Optional branch, tag, or commit. Omission requests the host's default revision. | +| `subdirectory` | Optional repository-relative selected folder, such as `packages/api`. Omission selects the repository root. | + +Clients MUST check the host capability. Hosts MUST reject unsupported repository input, explicit revisions without `revision: true`, and multiple entries without `multipleRepositories: true` **before preparation**. An omitted or empty list requests no repository preparation and retains directory/default creation. Generic `config` remains unchanged. + +##### Request and resolved directories + +Pass the repository list as context to configuration queries, then beside the resolved configuration in `createSession`: + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "createSession", + "params": { + "channel": "ahp-session:/new-session", + "repositories": [ + { + "source": "https://example.org/team/project.git", + "revision": "main", + "subdirectory": "packages/api" + } + ], + "config": { "mode": "interactive" } + } +} +``` + +A host that prepares a worktree could publish this resolved session state: + +```json +{ + "lifecycle": "ready", + "workingDirectories": [ + { + "uri": "file:///worktrees/session-42/packages/api", + "repo": "https://example.org/team/project.git", + "origin": { + "kind": "worktree", + "mainWorktree": "file:///workspaces/project" + } + } + ] +} +``` + +`SessionMetadata.workingDirectories` accepts URI strings and [`WorkingDirectory`](/reference/session#workingdirectory) records, shared by full state and summaries: + +| Record field | Meaning | +|---|---| +| `uri` | Required actual selected directory. The unique entry key, including when selecting a subdirectory. | +| `repo` | Optional credential-free source association, **not** a checkout identity. | +| `origin` | Optional host-reported result: `local`, `repo`, or `worktree`. `worktree` requires `mainWorktree: URI`. Omission is unspecified provenance. | + +`local` describes a supplied local directory; `repo` describes repository preparation without a host-created worktree; `worktree` describes a host-created worktree even when the input was a repository source. Multiple entries may share `repo`. Clients MUST NOT deduplicate by source, associate requests and results by array position, or infer a checkout root from the selected directory. Metadata does not grant cleanup authority or imply generic worktree-creation support. + +##### Host validation and lifecycle + +For creation and configuration queries, hosts MUST reject invalid or unsupported input with `InvalidParams` (`-32602`): empty sources or revisions, malformed or credential-bearing source URIs, and empty, absolute, or escaping `subdirectory` paths. Hosts MUST ensure the selected folder resolves inside the prepared repository, including through symbolic links, and MUST NOT silently substitute the root or a default revision. + +A non-empty `createSession.repositories` and a non-empty `createSession.workingDirectories` are mutually exclusive. Configuration queries may also include an existing `workingDirectory` as context but MUST NOT prepare repositories. Hosts MUST authorize the client before preparation, use the existing [authentication contract](./authentication), and keep credentials out of source URIs, state, progress, and logs. + +Preparation stays within the existing `creating` lifecycle. Before `session/ready` or `session/creationFailed`, the host MUST publish any actual resolved directories. Unresolved directories stay absent or empty; failure uses `creationError`, not a fabricated checkout. The original repository list is **not** echoed into session metadata. + +Clients MUST wait for native session readiness before sending turns and use the actual resolved directories, not requested sources or guessed paths. Optional `createSession.progressToken` / [`root/progress`](./root-channel#progress) updates and a successful command response do not establish readiness. + +Repository preparation does not change the agent's existing multi-directory, immutable-primary, or primary-replacement rules. Resolved directories must fit those capabilities; later directory changes remain available under the same rules. + +##### Compatibility and host authority + +Clients opt into rich records with `initialize.capabilities.workingDirectoryInfo: {}` ([`WorkingDirectoryInfoCapability`](/reference/common#workingdirectoryinfocapability)). New clients MUST also accept URI-only and mixed lists, including responses from older hosts. + +Hosts MUST retain authoritative rich state and project every record to its `uri` for clients without this capability. Projection is per client and MUST NOT erase metadata from shared state or the replay log. It applies to **every carrier**: + +- `initialize` and `subscribe` snapshots; +- `listSessions`, `root/sessionAdded`, and `root/sessionSummaryChanged` deltas; +- live and replayed `session/workingDirectorySet` / `session/workingDirectoryReplaced` actions; +- reconnect snapshots and any full-snapshot reset. + +The capability remains in effect across reconnect, for both replay and snapshot recovery. + +Set actions carry `directory: URI | WorkingDirectory`; replacement actions carry the target `directory: URI` and `replacement: URI | WorkingDirectory`; removal carries only a URI. Clients MUST NOT supply `repo` or non-local provenance. Hosts MUST validate and enrich client input before broadcasting the accepted action. A rich set replaces the complete same-URI record; a URI-only duplicate preserves known metadata. Remove and replace compare URI keys and preserve existing ordering and primary-slot rules; a URI-only replacement preserves metadata already known for its URI. + +Chats retain `workingDirectories?: URI[]`: each URI matches a session URI string or record's `uri`. Chats do not duplicate directory metadata. + +##### Lost creation responses and cleanup + +`createSession` remains non-idempotent. An existing URI returns `SessionAlreadyExists` (`-32003`) and MUST NOT start another preparation. There is no creation token or original-request echo. After a lost response, a duplicate error MUST NOT be treated as proof of successful creation, and clients MUST NOT automatically send pending turns on that basis. A user may explicitly reopen the existing session or retry with a new URI. Ordinary reconnect of an already attached session remains unchanged. + +The host owns resources it creates, not arbitrary existing or shared directories. This capability adds no cancellation RPC or disposal rule; `repo` and `origin` do not authorize deletion. + ### Active session Once a session reaches `lifecycle: 'ready'`, clients may create chats on it with [`createChat`](/reference/chat#createchat). Each chat is independently subscribable at its own `ahp-chat:/` URI; see the [Chat Channel specification](./chat-channel) for the per-chat lifecycle, turn flow, tool calls, and input request handling. diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 154cb2ac0..61de6c3ce 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -439,13 +439,20 @@ }, "SessionWorkingDirectorySetAction": { "type": "object", - "description": "A working directory was added to the session's\n{@link SessionState.workingDirectories} set.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the set does not already contain it (creating the set if\nabsent) and is a no-op when it is already present. Only valid when the agent\nadvertises {@link AgentCapabilities.multipleWorkingDirectories}.", + "description": "A working directory was added or updated in the session's\n{@link SessionState.workingDirectories} set.\n\nUpsert keyed by URI. A rich record replaces the existing entry in place;\na URI-only duplicate preserves known metadata. Only valid when the agent\nadvertises {@link AgentCapabilities.multipleWorkingDirectories}. Clients\nMUST omit `repo` and use only local or absent `origin`; the host validates\nand enriches the entry before accepting it.", "properties": { "type": { "const": "session/workingDirectorySet" }, "directory": { - "$ref": "#/$defs/URI", + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ], "description": "The working directory to grant the session's agent tool access to." } }, @@ -473,7 +480,7 @@ }, "SessionWorkingDirectoryReplacedAction": { "type": "object", - "description": "Atomically replaces one of the session's working directories.\n\nThis is a targeted compare-and-swap: the reducer is a no-op when\n{@link SessionState.workingDirectories} does not contain `directory`.\nOtherwise it replaces that entry with `replacement` and deduplicates the\nresult, preserving every other directory's relative order. When\n`replacement` occurs after the target, it moves to the target's position;\nfor example, `[A, B, C]` with `B → C` becomes `[A, C]`. When it occurs\nbefore the target, it retains its earlier position and the target is removed;\n`[A, B, C]` with `C → A` becomes `[A, B]`.\n\nOnly valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}. Replacing index `0`\nadditionally requires\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement}; clients\nMUST NOT target an immutable primary. The host MUST validate and apply its\nbackend side effect before broadcasting an accepted action, or reject it.", + "description": "Atomically replaces one of the session's working directories.\n\nThis is a targeted compare-and-swap: the reducer is a no-op when\n{@link SessionState.workingDirectories} does not contain `directory`.\nOtherwise it replaces that entry with `replacement` and deduplicates the\nresult, preserving every other directory's relative order. When\n`replacement` occurs after the target, it moves to the target's position;\nfor example, `[A, B, C]` with `B → C` becomes `[A, C]`. When it occurs\nbefore the target, it retains its earlier position and the target is removed;\n`[A, B, C]` with `C → A` becomes `[A, B]`.\nA rich replacement supplies the complete entry; a URI-only replacement\npreserves metadata already known for that URI.\n\nOnly valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}. Replacing index `0`\nadditionally requires\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement}; clients\nMUST NOT target an immutable primary. The host MUST validate and apply its\nbackend side effect before broadcasting an accepted action, or reject it.\nClients MUST omit `repo` and use only local or absent `origin`.", "properties": { "type": { "const": "session/workingDirectoryReplaced" @@ -483,8 +490,15 @@ "description": "URI of the existing entry to replace." }, "replacement": { - "$ref": "#/$defs/URI", - "description": "URI to place in the replaced entry's position." + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ], + "description": "Complete entry to place in the replaced entry's position." } }, "required": [ @@ -1462,7 +1476,7 @@ }, "ChatWorkingDirectorySetAction": { "type": "object", - "description": "A working directory was added to this chat's\n{@link ChatState.workingDirectories} subset.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the chat's subset does not already contain it (creating the\nsubset if absent) and is a no-op when it is already present. `directory` MUST\nbe one of the owning session's {@link SessionState.workingDirectories}; a host\nMUST reject a directory that is not. Only valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}.", + "description": "A working directory was added to this chat's\n{@link ChatState.workingDirectories} subset.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the chat's subset does not already contain it (creating the\nsubset if absent) and is a no-op when it is already present. `directory` MUST\nmatch a URI string or record's `uri` in the owning session's\n{@link SessionState.workingDirectories}; a host MUST reject a directory that\ndoes not. Only valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}.", "properties": { "type": { "const": "chat/workingDirectorySet" @@ -3493,6 +3507,65 @@ "run" ] }, + "LocalWorkingDirectoryOrigin": { + "type": "object", + "properties": { + "kind": { + "const": "local" + } + }, + "required": [ + "kind" + ] + }, + "RepoWorkingDirectoryOrigin": { + "type": "object", + "properties": { + "kind": { + "const": "repo" + } + }, + "required": [ + "kind" + ] + }, + "WorktreeWorkingDirectoryOrigin": { + "type": "object", + "properties": { + "kind": { + "const": "worktree" + }, + "mainWorktree": { + "$ref": "#/$defs/URI", + "description": "Main worktree associated with the host-prepared worktree." + } + }, + "required": [ + "kind", + "mainWorktree" + ] + }, + "WorkingDirectory": { + "type": "object", + "description": "An actual working directory, uniquely keyed by `uri` within the session.", + "properties": { + "uri": { + "$ref": "#/$defs/URI", + "description": "Actual selected directory, which may be a repository subdirectory." + }, + "repo": { + "$ref": "#/$defs/URI", + "description": "Credential-free repository source association, not a checkout identity." + }, + "origin": { + "$ref": "#/$defs/WorkingDirectoryOrigin", + "description": "Host-reported provenance; omission means unspecified." + } + }, + "required": [ + "uri" + ] + }, "SessionMetadata": { "type": "object", "description": "Metadata shared between the full {@link SessionState} (delivered when a\nclient subscribes to a session's URI) and the lightweight\n{@link SessionSummary} (carried in the root-channel session catalog).\n\nThese fields describe the session at a glance and appear in both places.\n`SessionState` owns the authoritative values for a subscribed session;\n`SessionSummary` mirrors them into the catalog so clients that only render a\nsession list don't have to subscribe to every session URI. The host keeps\nthe catalog in sync via `root/sessionSummaryChanged`.", @@ -3524,9 +3597,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -3570,9 +3650,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -3878,9 +3965,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -5281,7 +5375,7 @@ "items": { "$ref": "#/$defs/URI" }, - "description": "The subset of the session's\n{@link SessionState.workingDirectories | `workingDirectories`} that this\nchat's agent has tool access to. Every entry MUST be present in the owning\nsession's `workingDirectories`; servers MUST reject a\n`chat/workingDirectorySet` action that violates this constraint.\n\nWhen absent, the chat inherits the full session set. When present but empty\n(not recommended), the chat has no working-directory tool access at all.\n\nDispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to\nupdate the subset on a running chat." + "description": "The subset of the session's\n{@link SessionState.workingDirectories | `workingDirectories`} that this\nchat's agent has tool access to. Every URI MUST match a URI string or a\nrecord's `uri` in the owning session's set; servers MUST reject a\n`chat/workingDirectorySet` action that violates this constraint.\n\nWhen absent, the chat inherits the full session set. When present but empty\n(not recommended), the chat has no working-directory tool access at all.\n\nDirectory metadata belongs to the session, not the chat.\nDispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to\nupdate the subset on a running chat." }, "changesets": { "type": "array", @@ -8839,6 +8933,20 @@ "$ref": "#/$defs/AutomationSessionOrigin", "description": "Durable provenance for sessions created by a higher-level AHP workflow." }, + "WorkingDirectoryOrigin": { + "oneOf": [ + { + "$ref": "#/$defs/LocalWorkingDirectoryOrigin" + }, + { + "$ref": "#/$defs/RepoWorkingDirectoryOrigin" + }, + { + "$ref": "#/$defs/WorktreeWorkingDirectoryOrigin" + } + ], + "description": "Host-reported preparation result, independent of the creation input." + }, "SessionInputRequest": { "oneOf": [ { diff --git a/schema/commands.schema.json b/schema/commands.schema.json index dda6fae69..387858cbc 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -124,6 +124,10 @@ "type": "object", "description": "Optional capabilities a client declares during `initialize`.\n\nEach field is a presence flag: an empty object `{}` means \"supported\",\nabsence means \"not supported\". Sub-fields on individual capabilities\nare reserved for future per-capability options.", "properties": { + "workingDirectoryInfo": { + "$ref": "#/$defs/WorkingDirectoryInfoCapability", + "description": "Client accepts rich {@link WorkingDirectory} records as well as URI strings.\nHosts project records to URIs when absent and retain this choice on reconnect." + }, "mcpApps": { "type": "object", "additionalProperties": {}, @@ -131,6 +135,11 @@ } } }, + "WorkingDirectoryInfoCapability": { + "type": "object", + "description": "Presence capability for {@link ClientCapabilities.workingDirectoryInfo}.", + "properties": {} + }, "HostEnvironment": { "type": "object", "description": "Describes the connection's default host-side execution environment.\n\nThe descriptor applies to {@link InitializeResult.defaultDirectory},\nsession working directories, and `file:` resources handled by the server\nthrough the `resource*` commands, unless more specific environment metadata\nis available. It does not apply to resources provided by the client.\n\nA host that exposes mixed environments MUST only populate fields that have a\nsingle connection-wide default.", @@ -177,6 +186,10 @@ "$ref": "#/$defs/URI", "description": "Suggested default directory for remote filesystem browsing" }, + "repositoryPreparation": { + "$ref": "#/$defs/RepositoryPreparationCapability", + "description": "Host repository preparation support; absent when unsupported." + }, "completionTriggerCharacters": { "type": "array", "items": { @@ -216,6 +229,20 @@ "description": "Presence capability for {@link InitializeResult.multipleChatProviders}.\n\nStability: 1.0 - Early development.", "properties": {} }, + "RepositoryPreparationCapability": { + "type": "object", + "description": "Host support for repository preparation at session creation. Presence\nalone supports one repository at its default revision.", + "properties": { + "revision": { + "type": "boolean", + "description": "When true, clients may supply {@link RepositorySource.revision}." + }, + "multipleRepositories": { + "type": "boolean", + "description": "When true, clients may supply more than one repository." + } + } + }, "AutomationCapabilities": { "type": "object", "description": "Automation features supported by this host authority.\n\nThe presence of this object advertises the baseline `ahp-automations://`\ncatalogue. Optional fields describe additional host features and\nrestrictions.\n\nCapabilities describe implementation support.\n{@link AutomationEntry.operations} remains authoritative for which\ndefinition mutations are currently allowed on a particular automation.", @@ -1148,6 +1175,13 @@ "$ref": "#/$defs/URI", "description": "Working directory for the session" }, + "repositories": { + "type": "array", + "items": { + "$ref": "#/$defs/RepositorySource" + }, + "description": "Repositories used as configuration context; an empty list is equivalent to omission." + }, "config": { "type": "object", "additionalProperties": {}, @@ -1222,6 +1256,13 @@ "$ref": "#/$defs/URI", "description": "Working directory for the session" }, + "repositories": { + "type": "array", + "items": { + "$ref": "#/$defs/RepositorySource" + }, + "description": "Repositories used as configuration context; an empty list is equivalent to omission." + }, "config": { "type": "object", "additionalProperties": {}, @@ -1257,6 +1298,27 @@ "items" ] }, + "RepositorySource": { + "type": "object", + "description": "Requested repository source, not a resolved working directory.", + "properties": { + "source": { + "$ref": "#/$defs/URI", + "description": "Credential-free repository source URI." + }, + "revision": { + "type": "string", + "description": "Requested branch, tag, or commit. Omit to use the host's default revision." + }, + "subdirectory": { + "type": "string", + "description": "Repository-relative selected folder; omit for the root. Hosts reject empty, absolute, or escaping paths." + } + }, + "required": [ + "source" + ] + }, "CreateSessionParams": { "type": "object", "description": "Creates a new session with the specified agent provider.\n\nIf the session URI already exists, the server MUST return an error with code\n`-32003` (`SessionAlreadyExists`).\n\nAfter creation, the client should subscribe to the session URI to receive state\nupdates. The server also broadcasts a `root/sessionAdded` notification to all\nclients.", @@ -1281,6 +1343,13 @@ }, "description": "The working directories the session's agent is granted tool access to.\nA session may span multiple directories; they are equal peers except when\nthe agent advertises a protected-primary capability. An\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary | immutable\nprimary} is fixed, while a\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement | replaceable\nprimary} is changed only with `session/workingDirectoryReplaced`.\n\nA client MUST NOT supply more than one entry unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}; a server without that\ncapability treats only the first entry as the session's working directory\nand ignores the rest. Dispatch working-directory actions to change the set\nafter the session has started." }, + "repositories": { + "type": "array", + "items": { + "$ref": "#/$defs/RepositorySource" + }, + "description": "Repositories to prepare instead of an explicit `workingDirectories` list.\nAn empty list is equivalent to omission." + }, "config": { "type": "object", "additionalProperties": {}, @@ -1499,7 +1568,7 @@ "items": { "$ref": "#/$defs/URI" }, - "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (those whose `source.kind` is `\"fork\"`) inherit\nthe source chat's `workingDirectories`; this field is ignored for forks.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." + "description": "Initial working-directory URI subset for this chat. Every URI MUST match\na URI string or record's `uri` in the owning session's `workingDirectories`;\nthe server MUST reject any entry that does not. When absent, the chat inherits the full\nsession set. Forked chats (those whose `source.kind` is `\"fork\"`) inherit\nthe source chat's `workingDirectories`; this field is ignored for forks.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." }, "title": { "type": "string", @@ -2825,6 +2894,65 @@ "run" ] }, + "LocalWorkingDirectoryOrigin": { + "type": "object", + "properties": { + "kind": { + "const": "local" + } + }, + "required": [ + "kind" + ] + }, + "RepoWorkingDirectoryOrigin": { + "type": "object", + "properties": { + "kind": { + "const": "repo" + } + }, + "required": [ + "kind" + ] + }, + "WorktreeWorkingDirectoryOrigin": { + "type": "object", + "properties": { + "kind": { + "const": "worktree" + }, + "mainWorktree": { + "$ref": "#/$defs/URI", + "description": "Main worktree associated with the host-prepared worktree." + } + }, + "required": [ + "kind", + "mainWorktree" + ] + }, + "WorkingDirectory": { + "type": "object", + "description": "An actual working directory, uniquely keyed by `uri` within the session.", + "properties": { + "uri": { + "$ref": "#/$defs/URI", + "description": "Actual selected directory, which may be a repository subdirectory." + }, + "repo": { + "$ref": "#/$defs/URI", + "description": "Credential-free repository source association, not a checkout identity." + }, + "origin": { + "$ref": "#/$defs/WorkingDirectoryOrigin", + "description": "Host-reported provenance; omission means unspecified." + } + }, + "required": [ + "uri" + ] + }, "SessionMetadata": { "type": "object", "description": "Metadata shared between the full {@link SessionState} (delivered when a\nclient subscribes to a session's URI) and the lightweight\n{@link SessionSummary} (carried in the root-channel session catalog).\n\nThese fields describe the session at a glance and appear in both places.\n`SessionState` owns the authoritative values for a subscribed session;\n`SessionSummary` mirrors them into the catalog so clients that only render a\nsession list don't have to subscribe to every session URI. The host keeps\nthe catalog in sync via `root/sessionSummaryChanged`.", @@ -2856,9 +2984,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -2902,9 +3037,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -3210,9 +3352,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -4613,7 +4762,7 @@ "items": { "$ref": "#/$defs/URI" }, - "description": "The subset of the session's\n{@link SessionState.workingDirectories | `workingDirectories`} that this\nchat's agent has tool access to. Every entry MUST be present in the owning\nsession's `workingDirectories`; servers MUST reject a\n`chat/workingDirectorySet` action that violates this constraint.\n\nWhen absent, the chat inherits the full session set. When present but empty\n(not recommended), the chat has no working-directory tool access at all.\n\nDispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to\nupdate the subset on a running chat." + "description": "The subset of the session's\n{@link SessionState.workingDirectories | `workingDirectories`} that this\nchat's agent has tool access to. Every URI MUST match a URI string or a\nrecord's `uri` in the owning session's set; servers MUST reject a\n`chat/workingDirectorySet` action that violates this constraint.\n\nWhen absent, the chat inherits the full session set. When present but empty\n(not recommended), the chat has no working-directory tool access at all.\n\nDirectory metadata belongs to the session, not the chat.\nDispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to\nupdate the subset on a running chat." }, "changesets": { "type": "array", @@ -8565,13 +8714,20 @@ }, "SessionWorkingDirectorySetAction": { "type": "object", - "description": "A working directory was added to the session's\n{@link SessionState.workingDirectories} set.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the set does not already contain it (creating the set if\nabsent) and is a no-op when it is already present. Only valid when the agent\nadvertises {@link AgentCapabilities.multipleWorkingDirectories}.", + "description": "A working directory was added or updated in the session's\n{@link SessionState.workingDirectories} set.\n\nUpsert keyed by URI. A rich record replaces the existing entry in place;\na URI-only duplicate preserves known metadata. Only valid when the agent\nadvertises {@link AgentCapabilities.multipleWorkingDirectories}. Clients\nMUST omit `repo` and use only local or absent `origin`; the host validates\nand enriches the entry before accepting it.", "properties": { "type": { "const": "session/workingDirectorySet" }, "directory": { - "$ref": "#/$defs/URI", + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ], "description": "The working directory to grant the session's agent tool access to." } }, @@ -8599,7 +8755,7 @@ }, "SessionWorkingDirectoryReplacedAction": { "type": "object", - "description": "Atomically replaces one of the session's working directories.\n\nThis is a targeted compare-and-swap: the reducer is a no-op when\n{@link SessionState.workingDirectories} does not contain `directory`.\nOtherwise it replaces that entry with `replacement` and deduplicates the\nresult, preserving every other directory's relative order. When\n`replacement` occurs after the target, it moves to the target's position;\nfor example, `[A, B, C]` with `B → C` becomes `[A, C]`. When it occurs\nbefore the target, it retains its earlier position and the target is removed;\n`[A, B, C]` with `C → A` becomes `[A, B]`.\n\nOnly valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}. Replacing index `0`\nadditionally requires\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement}; clients\nMUST NOT target an immutable primary. The host MUST validate and apply its\nbackend side effect before broadcasting an accepted action, or reject it.", + "description": "Atomically replaces one of the session's working directories.\n\nThis is a targeted compare-and-swap: the reducer is a no-op when\n{@link SessionState.workingDirectories} does not contain `directory`.\nOtherwise it replaces that entry with `replacement` and deduplicates the\nresult, preserving every other directory's relative order. When\n`replacement` occurs after the target, it moves to the target's position;\nfor example, `[A, B, C]` with `B → C` becomes `[A, C]`. When it occurs\nbefore the target, it retains its earlier position and the target is removed;\n`[A, B, C]` with `C → A` becomes `[A, B]`.\nA rich replacement supplies the complete entry; a URI-only replacement\npreserves metadata already known for that URI.\n\nOnly valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}. Replacing index `0`\nadditionally requires\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement}; clients\nMUST NOT target an immutable primary. The host MUST validate and apply its\nbackend side effect before broadcasting an accepted action, or reject it.\nClients MUST omit `repo` and use only local or absent `origin`.", "properties": { "type": { "const": "session/workingDirectoryReplaced" @@ -8609,8 +8765,15 @@ "description": "URI of the existing entry to replace." }, "replacement": { - "$ref": "#/$defs/URI", - "description": "URI to place in the replaced entry's position." + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ], + "description": "Complete entry to place in the replaced entry's position." } }, "required": [ @@ -9588,7 +9751,7 @@ }, "ChatWorkingDirectorySetAction": { "type": "object", - "description": "A working directory was added to this chat's\n{@link ChatState.workingDirectories} subset.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the chat's subset does not already contain it (creating the\nsubset if absent) and is a no-op when it is already present. `directory` MUST\nbe one of the owning session's {@link SessionState.workingDirectories}; a host\nMUST reject a directory that is not. Only valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}.", + "description": "A working directory was added to this chat's\n{@link ChatState.workingDirectories} subset.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the chat's subset does not already contain it (creating the\nsubset if absent) and is a no-op when it is already present. `directory` MUST\nmatch a URI string or record's `uri` in the owning session's\n{@link SessionState.workingDirectories}; a host MUST reject a directory that\ndoes not. Only valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}.", "properties": { "type": { "const": "chat/workingDirectorySet" @@ -11188,6 +11351,20 @@ "type": "string", "description": "Policy configuration state for a model." }, + "WorkingDirectoryOrigin": { + "oneOf": [ + { + "$ref": "#/$defs/LocalWorkingDirectoryOrigin" + }, + { + "$ref": "#/$defs/RepoWorkingDirectoryOrigin" + }, + { + "$ref": "#/$defs/WorktreeWorkingDirectoryOrigin" + } + ], + "description": "Host-reported preparation result, independent of the creation input." + }, "SessionStatus": { "type": "integer", "minimum": 0, diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 20d2481de..90acc6b2a 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -866,6 +866,65 @@ "run" ] }, + "LocalWorkingDirectoryOrigin": { + "type": "object", + "properties": { + "kind": { + "const": "local" + } + }, + "required": [ + "kind" + ] + }, + "RepoWorkingDirectoryOrigin": { + "type": "object", + "properties": { + "kind": { + "const": "repo" + } + }, + "required": [ + "kind" + ] + }, + "WorktreeWorkingDirectoryOrigin": { + "type": "object", + "properties": { + "kind": { + "const": "worktree" + }, + "mainWorktree": { + "$ref": "#/$defs/URI", + "description": "Main worktree associated with the host-prepared worktree." + } + }, + "required": [ + "kind", + "mainWorktree" + ] + }, + "WorkingDirectory": { + "type": "object", + "description": "An actual working directory, uniquely keyed by `uri` within the session.", + "properties": { + "uri": { + "$ref": "#/$defs/URI", + "description": "Actual selected directory, which may be a repository subdirectory." + }, + "repo": { + "$ref": "#/$defs/URI", + "description": "Credential-free repository source association, not a checkout identity." + }, + "origin": { + "$ref": "#/$defs/WorkingDirectoryOrigin", + "description": "Host-reported provenance; omission means unspecified." + } + }, + "required": [ + "uri" + ] + }, "SessionMetadata": { "type": "object", "description": "Metadata shared between the full {@link SessionState} (delivered when a\nclient subscribes to a session's URI) and the lightweight\n{@link SessionSummary} (carried in the root-channel session catalog).\n\nThese fields describe the session at a glance and appear in both places.\n`SessionState` owns the authoritative values for a subscribed session;\n`SessionSummary` mirrors them into the catalog so clients that only render a\nsession list don't have to subscribe to every session URI. The host keeps\nthe catalog in sync via `root/sessionSummaryChanged`.", @@ -897,9 +956,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -943,9 +1009,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -1251,9 +1324,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -2654,7 +2734,7 @@ "items": { "$ref": "#/$defs/URI" }, - "description": "The subset of the session's\n{@link SessionState.workingDirectories | `workingDirectories`} that this\nchat's agent has tool access to. Every entry MUST be present in the owning\nsession's `workingDirectories`; servers MUST reject a\n`chat/workingDirectorySet` action that violates this constraint.\n\nWhen absent, the chat inherits the full session set. When present but empty\n(not recommended), the chat has no working-directory tool access at all.\n\nDispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to\nupdate the subset on a running chat." + "description": "The subset of the session's\n{@link SessionState.workingDirectories | `workingDirectories`} that this\nchat's agent has tool access to. Every URI MUST match a URI string or a\nrecord's `uri` in the owning session's set; servers MUST reject a\n`chat/workingDirectorySet` action that violates this constraint.\n\nWhen absent, the chat inherits the full session set. When present but empty\n(not recommended), the chat has no working-directory tool access at all.\n\nDirectory metadata belongs to the session, not the chat.\nDispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to\nupdate the subset on a running chat." }, "changesets": { "type": "array", @@ -6291,6 +6371,10 @@ "type": "object", "description": "Optional capabilities a client declares during `initialize`.\n\nEach field is a presence flag: an empty object `{}` means \"supported\",\nabsence means \"not supported\". Sub-fields on individual capabilities\nare reserved for future per-capability options.", "properties": { + "workingDirectoryInfo": { + "$ref": "#/$defs/WorkingDirectoryInfoCapability", + "description": "Client accepts rich {@link WorkingDirectory} records as well as URI strings.\nHosts project records to URIs when absent and retain this choice on reconnect." + }, "mcpApps": { "type": "object", "additionalProperties": {}, @@ -6298,6 +6382,11 @@ } } }, + "WorkingDirectoryInfoCapability": { + "type": "object", + "description": "Presence capability for {@link ClientCapabilities.workingDirectoryInfo}.", + "properties": {} + }, "HostEnvironment": { "type": "object", "description": "Describes the connection's default host-side execution environment.\n\nThe descriptor applies to {@link InitializeResult.defaultDirectory},\nsession working directories, and `file:` resources handled by the server\nthrough the `resource*` commands, unless more specific environment metadata\nis available. It does not apply to resources provided by the client.\n\nA host that exposes mixed environments MUST only populate fields that have a\nsingle connection-wide default.", @@ -6344,6 +6433,10 @@ "$ref": "#/$defs/URI", "description": "Suggested default directory for remote filesystem browsing" }, + "repositoryPreparation": { + "$ref": "#/$defs/RepositoryPreparationCapability", + "description": "Host repository preparation support; absent when unsupported." + }, "completionTriggerCharacters": { "type": "array", "items": { @@ -6383,6 +6476,20 @@ "description": "Presence capability for {@link InitializeResult.multipleChatProviders}.\n\nStability: 1.0 - Early development.", "properties": {} }, + "RepositoryPreparationCapability": { + "type": "object", + "description": "Host support for repository preparation at session creation. Presence\nalone supports one repository at its default revision.", + "properties": { + "revision": { + "type": "boolean", + "description": "When true, clients may supply {@link RepositorySource.revision}." + }, + "multipleRepositories": { + "type": "boolean", + "description": "When true, clients may supply more than one repository." + } + } + }, "AutomationCapabilities": { "type": "object", "description": "Automation features supported by this host authority.\n\nThe presence of this object advertises the baseline `ahp-automations://`\ncatalogue. Optional fields describe additional host features and\nrestrictions.\n\nCapabilities describe implementation support.\n{@link AutomationEntry.operations} remains authoritative for which\ndefinition mutations are currently allowed on a particular automation.", @@ -7315,6 +7422,13 @@ "$ref": "#/$defs/URI", "description": "Working directory for the session" }, + "repositories": { + "type": "array", + "items": { + "$ref": "#/$defs/RepositorySource" + }, + "description": "Repositories used as configuration context; an empty list is equivalent to omission." + }, "config": { "type": "object", "additionalProperties": {}, @@ -7389,6 +7503,13 @@ "$ref": "#/$defs/URI", "description": "Working directory for the session" }, + "repositories": { + "type": "array", + "items": { + "$ref": "#/$defs/RepositorySource" + }, + "description": "Repositories used as configuration context; an empty list is equivalent to omission." + }, "config": { "type": "object", "additionalProperties": {}, @@ -7424,6 +7545,27 @@ "items" ] }, + "RepositorySource": { + "type": "object", + "description": "Requested repository source, not a resolved working directory.", + "properties": { + "source": { + "$ref": "#/$defs/URI", + "description": "Credential-free repository source URI." + }, + "revision": { + "type": "string", + "description": "Requested branch, tag, or commit. Omit to use the host's default revision." + }, + "subdirectory": { + "type": "string", + "description": "Repository-relative selected folder; omit for the root. Hosts reject empty, absolute, or escaping paths." + } + }, + "required": [ + "source" + ] + }, "CreateSessionParams": { "type": "object", "description": "Creates a new session with the specified agent provider.\n\nIf the session URI already exists, the server MUST return an error with code\n`-32003` (`SessionAlreadyExists`).\n\nAfter creation, the client should subscribe to the session URI to receive state\nupdates. The server also broadcasts a `root/sessionAdded` notification to all\nclients.", @@ -7448,6 +7590,13 @@ }, "description": "The working directories the session's agent is granted tool access to.\nA session may span multiple directories; they are equal peers except when\nthe agent advertises a protected-primary capability. An\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary | immutable\nprimary} is fixed, while a\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement | replaceable\nprimary} is changed only with `session/workingDirectoryReplaced`.\n\nA client MUST NOT supply more than one entry unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}; a server without that\ncapability treats only the first entry as the session's working directory\nand ignores the rest. Dispatch working-directory actions to change the set\nafter the session has started." }, + "repositories": { + "type": "array", + "items": { + "$ref": "#/$defs/RepositorySource" + }, + "description": "Repositories to prepare instead of an explicit `workingDirectories` list.\nAn empty list is equivalent to omission." + }, "config": { "type": "object", "additionalProperties": {}, @@ -7666,7 +7815,7 @@ "items": { "$ref": "#/$defs/URI" }, - "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (those whose `source.kind` is `\"fork\"`) inherit\nthe source chat's `workingDirectories`; this field is ignored for forks.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." + "description": "Initial working-directory URI subset for this chat. Every URI MUST match\na URI string or record's `uri` in the owning session's `workingDirectories`;\nthe server MUST reject any entry that does not. When absent, the chat inherits the full\nsession set. Forked chats (those whose `source.kind` is `\"fork\"`) inherit\nthe source chat's `workingDirectories`; this field is ignored for forks.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." }, "title": { "type": "string", @@ -8177,6 +8326,20 @@ "type": "string", "description": "Policy configuration state for a model." }, + "WorkingDirectoryOrigin": { + "oneOf": [ + { + "$ref": "#/$defs/LocalWorkingDirectoryOrigin" + }, + { + "$ref": "#/$defs/RepoWorkingDirectoryOrigin" + }, + { + "$ref": "#/$defs/WorktreeWorkingDirectoryOrigin" + } + ], + "description": "Host-reported preparation result, independent of the creation input." + }, "SessionStatus": { "type": "integer", "minimum": 0, @@ -9700,13 +9863,20 @@ }, "SessionWorkingDirectorySetAction": { "type": "object", - "description": "A working directory was added to the session's\n{@link SessionState.workingDirectories} set.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the set does not already contain it (creating the set if\nabsent) and is a no-op when it is already present. Only valid when the agent\nadvertises {@link AgentCapabilities.multipleWorkingDirectories}.", + "description": "A working directory was added or updated in the session's\n{@link SessionState.workingDirectories} set.\n\nUpsert keyed by URI. A rich record replaces the existing entry in place;\na URI-only duplicate preserves known metadata. Only valid when the agent\nadvertises {@link AgentCapabilities.multipleWorkingDirectories}. Clients\nMUST omit `repo` and use only local or absent `origin`; the host validates\nand enriches the entry before accepting it.", "properties": { "type": { "const": "session/workingDirectorySet" }, "directory": { - "$ref": "#/$defs/URI", + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ], "description": "The working directory to grant the session's agent tool access to." } }, @@ -9734,7 +9904,7 @@ }, "SessionWorkingDirectoryReplacedAction": { "type": "object", - "description": "Atomically replaces one of the session's working directories.\n\nThis is a targeted compare-and-swap: the reducer is a no-op when\n{@link SessionState.workingDirectories} does not contain `directory`.\nOtherwise it replaces that entry with `replacement` and deduplicates the\nresult, preserving every other directory's relative order. When\n`replacement` occurs after the target, it moves to the target's position;\nfor example, `[A, B, C]` with `B → C` becomes `[A, C]`. When it occurs\nbefore the target, it retains its earlier position and the target is removed;\n`[A, B, C]` with `C → A` becomes `[A, B]`.\n\nOnly valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}. Replacing index `0`\nadditionally requires\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement}; clients\nMUST NOT target an immutable primary. The host MUST validate and apply its\nbackend side effect before broadcasting an accepted action, or reject it.", + "description": "Atomically replaces one of the session's working directories.\n\nThis is a targeted compare-and-swap: the reducer is a no-op when\n{@link SessionState.workingDirectories} does not contain `directory`.\nOtherwise it replaces that entry with `replacement` and deduplicates the\nresult, preserving every other directory's relative order. When\n`replacement` occurs after the target, it moves to the target's position;\nfor example, `[A, B, C]` with `B → C` becomes `[A, C]`. When it occurs\nbefore the target, it retains its earlier position and the target is removed;\n`[A, B, C]` with `C → A` becomes `[A, B]`.\nA rich replacement supplies the complete entry; a URI-only replacement\npreserves metadata already known for that URI.\n\nOnly valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}. Replacing index `0`\nadditionally requires\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement}; clients\nMUST NOT target an immutable primary. The host MUST validate and apply its\nbackend side effect before broadcasting an accepted action, or reject it.\nClients MUST omit `repo` and use only local or absent `origin`.", "properties": { "type": { "const": "session/workingDirectoryReplaced" @@ -9744,8 +9914,15 @@ "description": "URI of the existing entry to replace." }, "replacement": { - "$ref": "#/$defs/URI", - "description": "URI to place in the replaced entry's position." + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ], + "description": "Complete entry to place in the replaced entry's position." } }, "required": [ @@ -10698,7 +10875,7 @@ }, "ChatWorkingDirectorySetAction": { "type": "object", - "description": "A working directory was added to this chat's\n{@link ChatState.workingDirectories} subset.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the chat's subset does not already contain it (creating the\nsubset if absent) and is a no-op when it is already present. `directory` MUST\nbe one of the owning session's {@link SessionState.workingDirectories}; a host\nMUST reject a directory that is not. Only valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}.", + "description": "A working directory was added to this chat's\n{@link ChatState.workingDirectories} subset.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the chat's subset does not already contain it (creating the\nsubset if absent) and is a no-op when it is already present. `directory` MUST\nmatch a URI string or record's `uri` in the owning session's\n{@link SessionState.workingDirectories}; a host MUST reject a directory that\ndoes not. Only valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}.", "properties": { "type": { "const": "chat/workingDirectorySet" diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 40702c709..9861edd75 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -166,9 +166,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -1168,6 +1175,65 @@ "run" ] }, + "LocalWorkingDirectoryOrigin": { + "type": "object", + "properties": { + "kind": { + "const": "local" + } + }, + "required": [ + "kind" + ] + }, + "RepoWorkingDirectoryOrigin": { + "type": "object", + "properties": { + "kind": { + "const": "repo" + } + }, + "required": [ + "kind" + ] + }, + "WorktreeWorkingDirectoryOrigin": { + "type": "object", + "properties": { + "kind": { + "const": "worktree" + }, + "mainWorktree": { + "$ref": "#/$defs/URI", + "description": "Main worktree associated with the host-prepared worktree." + } + }, + "required": [ + "kind", + "mainWorktree" + ] + }, + "WorkingDirectory": { + "type": "object", + "description": "An actual working directory, uniquely keyed by `uri` within the session.", + "properties": { + "uri": { + "$ref": "#/$defs/URI", + "description": "Actual selected directory, which may be a repository subdirectory." + }, + "repo": { + "$ref": "#/$defs/URI", + "description": "Credential-free repository source association, not a checkout identity." + }, + "origin": { + "$ref": "#/$defs/WorkingDirectoryOrigin", + "description": "Host-reported provenance; omission means unspecified." + } + }, + "required": [ + "uri" + ] + }, "SessionMetadata": { "type": "object", "description": "Metadata shared between the full {@link SessionState} (delivered when a\nclient subscribes to a session's URI) and the lightweight\n{@link SessionSummary} (carried in the root-channel session catalog).\n\nThese fields describe the session at a glance and appear in both places.\n`SessionState` owns the authoritative values for a subscribed session;\n`SessionSummary` mirrors them into the catalog so clients that only render a\nsession list don't have to subscribe to every session URI. The host keeps\nthe catalog in sync via `root/sessionSummaryChanged`.", @@ -1199,9 +1265,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -1245,9 +1318,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -1553,9 +1633,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -2956,7 +3043,7 @@ "items": { "$ref": "#/$defs/URI" }, - "description": "The subset of the session's\n{@link SessionState.workingDirectories | `workingDirectories`} that this\nchat's agent has tool access to. Every entry MUST be present in the owning\nsession's `workingDirectories`; servers MUST reject a\n`chat/workingDirectorySet` action that violates this constraint.\n\nWhen absent, the chat inherits the full session set. When present but empty\n(not recommended), the chat has no working-directory tool access at all.\n\nDispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to\nupdate the subset on a running chat." + "description": "The subset of the session's\n{@link SessionState.workingDirectories | `workingDirectories`} that this\nchat's agent has tool access to. Every URI MUST match a URI string or a\nrecord's `uri` in the owning session's set; servers MUST reject a\n`chat/workingDirectorySet` action that violates this constraint.\n\nWhen absent, the chat inherits the full session set. When present but empty\n(not recommended), the chat has no working-directory tool access at all.\n\nDirectory metadata belongs to the session, not the chat.\nDispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to\nupdate the subset on a running chat." }, "changesets": { "type": "array", @@ -6545,6 +6632,20 @@ "type": "string", "description": "Policy configuration state for a model." }, + "WorkingDirectoryOrigin": { + "oneOf": [ + { + "$ref": "#/$defs/LocalWorkingDirectoryOrigin" + }, + { + "$ref": "#/$defs/RepoWorkingDirectoryOrigin" + }, + { + "$ref": "#/$defs/WorktreeWorkingDirectoryOrigin" + } + ], + "description": "Host-reported preparation result, independent of the creation input." + }, "SessionLifecycle": { "enum": [ "creating", diff --git a/schema/state.schema.json b/schema/state.schema.json index 20c80d8cb..6009a349a 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -777,6 +777,65 @@ "run" ] }, + "LocalWorkingDirectoryOrigin": { + "type": "object", + "properties": { + "kind": { + "const": "local" + } + }, + "required": [ + "kind" + ] + }, + "RepoWorkingDirectoryOrigin": { + "type": "object", + "properties": { + "kind": { + "const": "repo" + } + }, + "required": [ + "kind" + ] + }, + "WorktreeWorkingDirectoryOrigin": { + "type": "object", + "properties": { + "kind": { + "const": "worktree" + }, + "mainWorktree": { + "$ref": "#/$defs/URI", + "description": "Main worktree associated with the host-prepared worktree." + } + }, + "required": [ + "kind", + "mainWorktree" + ] + }, + "WorkingDirectory": { + "type": "object", + "description": "An actual working directory, uniquely keyed by `uri` within the session.", + "properties": { + "uri": { + "$ref": "#/$defs/URI", + "description": "Actual selected directory, which may be a repository subdirectory." + }, + "repo": { + "$ref": "#/$defs/URI", + "description": "Credential-free repository source association, not a checkout identity." + }, + "origin": { + "$ref": "#/$defs/WorkingDirectoryOrigin", + "description": "Host-reported provenance; omission means unspecified." + } + }, + "required": [ + "uri" + ] + }, "SessionMetadata": { "type": "object", "description": "Metadata shared between the full {@link SessionState} (delivered when a\nclient subscribes to a session's URI) and the lightweight\n{@link SessionSummary} (carried in the root-channel session catalog).\n\nThese fields describe the session at a glance and appear in both places.\n`SessionState` owns the authoritative values for a subscribed session;\n`SessionSummary` mirrors them into the catalog so clients that only render a\nsession list don't have to subscribe to every session URI. The host keeps\nthe catalog in sync via `root/sessionSummaryChanged`.", @@ -808,9 +867,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -854,9 +920,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -1162,9 +1235,16 @@ "workingDirectories": { "type": "array", "items": { - "$ref": "#/$defs/URI" + "oneOf": [ + { + "$ref": "#/$defs/URI" + }, + { + "$ref": "#/$defs/WorkingDirectory" + } + ] }, - "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set." + "description": "The working directories the session's agent has tool access to, as\nmaintained by working-directory actions. Directories are equal peers except\nwhen the agent advertises\n{@link MultipleWorkingDirectoriesCapability.immutablePrimary} without\n{@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first\nentry is then a fixed process root), or advertises `primaryReplacement`\n(the first entry is a protected, replaceable primary slot). Individual chats\nMAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`}; a\nchat that sets none operates against this full set.\nEntries are uniquely keyed by URI. Rich records require\n{@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -2565,7 +2645,7 @@ "items": { "$ref": "#/$defs/URI" }, - "description": "The subset of the session's\n{@link SessionState.workingDirectories | `workingDirectories`} that this\nchat's agent has tool access to. Every entry MUST be present in the owning\nsession's `workingDirectories`; servers MUST reject a\n`chat/workingDirectorySet` action that violates this constraint.\n\nWhen absent, the chat inherits the full session set. When present but empty\n(not recommended), the chat has no working-directory tool access at all.\n\nDispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to\nupdate the subset on a running chat." + "description": "The subset of the session's\n{@link SessionState.workingDirectories | `workingDirectories`} that this\nchat's agent has tool access to. Every URI MUST match a URI string or a\nrecord's `uri` in the owning session's set; servers MUST reject a\n`chat/workingDirectorySet` action that violates this constraint.\n\nWhen absent, the chat inherits the full session set. When present but empty\n(not recommended), the chat has no working-directory tool access at all.\n\nDirectory metadata belongs to the session, not the chat.\nDispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to\nupdate the subset on a running chat." }, "changesets": { "type": "array", @@ -6123,6 +6203,20 @@ "$ref": "#/$defs/AutomationSessionOrigin", "description": "Durable provenance for sessions created by a higher-level AHP workflow." }, + "WorkingDirectoryOrigin": { + "oneOf": [ + { + "$ref": "#/$defs/LocalWorkingDirectoryOrigin" + }, + { + "$ref": "#/$defs/RepoWorkingDirectoryOrigin" + }, + { + "$ref": "#/$defs/WorktreeWorkingDirectoryOrigin" + } + ], + "description": "Host-reported preparation result, independent of the creation input." + }, "SessionInputRequest": { "oneOf": [ { diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts index f427e35b3..73bacdd3e 100644 --- a/scripts/generate-csharp.ts +++ b/scripts/generate-csharp.ts @@ -173,6 +173,7 @@ function mapType(tsType: string): string { if (tsType === 'URI') return 'string'; if (tsType === 'StringOrMarkdown') return 'StringOrMarkdown'; + if (tsType === 'URI | WorkingDirectory') return 'WorkingDirectoryEntry'; // ChildCustomizationType is a TS-only subset alias of CustomizationType. if (tsType === 'ChildCustomizationType') return 'CustomizationType'; @@ -761,7 +762,7 @@ function generateDiscriminatedUnion(project: Project, cfg: UnionConfig): string const STATE_ENUMS = [ 'ChannelRecoveryKind', 'PolicyState', 'PendingMessageKind', 'SessionLifecycle', 'SessionStatus', - 'SessionOriginKind', + 'SessionOriginKind', 'WorkingDirectoryOriginKind', 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', @@ -793,6 +794,10 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: strin { name: 'AgentCapabilities' }, { name: 'MultipleChatsCapability' }, { name: 'MultipleWorkingDirectoriesCapability' }, + { name: 'WorkingDirectory' }, + { name: 'LocalWorkingDirectoryOrigin' }, + { name: 'RepoWorkingDirectoryOrigin' }, + { name: 'WorktreeWorkingDirectoryOrigin' }, { name: 'SessionModelInfo' }, { name: 'ModelSelection' }, { name: 'AgentSelection' }, @@ -1034,6 +1039,37 @@ const TERMINAL_CONTENT_PART_UNION: UnionConfig = { // inline form, an object is the content reference. Mirrors rust's // `#[serde(untagged)] enum ToolInput { Inline(String), ContentRef(ContentRef) }` // (clients/rust/crates/ahp-types/src/state.rs) and swift's `.inline` / `.contentRef`. +const WORKING_DIRECTORY_ENTRY_CS = `/// A legacy URI or a complete working-directory record. +[JsonConverter(typeof(WorkingDirectoryEntryConverter))] +public sealed class WorkingDirectoryEntry +{ + public WorkingDirectoryEntry(string uri) { Uri = uri; } + public WorkingDirectoryEntry(WorkingDirectory directory) { Uri = directory.Uri; Directory = directory; } + + public string Uri { get; } + public WorkingDirectory? Directory { get; } +} + +internal sealed class WorkingDirectoryEntryConverter : JsonConverter +{ + public override WorkingDirectoryEntry Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return new WorkingDirectoryEntry(reader.GetString()!); + } + return new WorkingDirectoryEntry( + JsonSerializer.Deserialize(ref reader, AhpJsonTypeInfo.Get(options)) + ?? throw new JsonException("WorkingDirectoryEntry requires a URI or directory")); + } + + public override void Write(Utf8JsonWriter writer, WorkingDirectoryEntry value, JsonSerializerOptions options) + { + if (value.Directory is null) { writer.WriteStringValue(value.Uri); return; } + JsonSerializer.Serialize(writer, value.Directory, AhpJsonTypeInfo.Get(options)); + } +}`; + const TOOL_INPUT_UNION_CS = `/// /// ToolInput is a tool call's raw input, carried either inline as a string or by /// content reference for inputs large enough to be fetched lazily. @@ -1329,6 +1365,18 @@ const TERMINAL_LIFECYCLE_STATE_UNION: UnionConfig = { ], }; +const WORKING_DIRECTORY_ORIGIN_UNION: UnionConfig = { + name: 'WorkingDirectoryOrigin', + discriminantField: 'kind', + doc: 'Host-reported working-directory provenance.', + variants: [ + { variantName: 'Local', innerType: 'LocalWorkingDirectoryOrigin', wireValue: 'local' }, + { variantName: 'Repo', innerType: 'RepoWorkingDirectoryOrigin', wireValue: 'repo' }, + { variantName: 'Worktree', innerType: 'WorktreeWorkingDirectoryOrigin', wireValue: 'worktree' }, + ], + unknown: true, +}; + const SESSION_ORIGIN_UNION: UnionConfig = { name: 'SessionOrigin', discriminantField: 'kind', @@ -1617,7 +1665,7 @@ function generateStateFile(project: Project): string { 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, BACKGROUND_WORK_UNION, - TERMINAL_LIFECYCLE_STATE_UNION, SESSION_ORIGIN_UNION, AUTOMATION_TRIGGER_UNION, + TERMINAL_LIFECYCLE_STATE_UNION, SESSION_ORIGIN_UNION, WORKING_DIRECTORY_ORIGIN_UNION, AUTOMATION_TRIGGER_UNION, AUTOMATION_DISABLE_CONDITION_UNION, AUTOMATION_RUN_ORIGIN_UNION, AUTOMATION_RUN_LIFECYCLE_UNION, ]) { @@ -1628,6 +1676,8 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(TOOL_INPUT_UNION_CS); lines.push(''); + lines.push(WORKING_DIRECTORY_ENTRY_CS); + lines.push(''); lines.push(generateSnapshotState()); lines.push(''); @@ -2316,6 +2366,7 @@ function generateActionsFile(project: Project): string { const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ChatMoveDestinationKind', 'PathStyle', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode', 'AuthenticateTargetKind']; const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: string }[] = [ + { name: 'RepositorySource' }, { name: 'CreateTcpConnectionParams' }, { name: 'CreateTcpConnectionResult' }, { name: 'InitializeParams' }, { name: 'HostEnvironment' }, { name: 'InitializeResult' }, // Implementation identity carried by InitializeParams.clientInfo / @@ -2323,6 +2374,7 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: str // record or those fields reference a non-existent type (CS0246). { name: 'Implementation' }, { name: 'ClientCapabilities' }, + { name: 'RepositoryPreparationCapability' }, { name: 'WorkingDirectoryInfoCapability' }, { name: 'AutomationCapabilities' }, { name: 'AutomationCreateCapability' }, { name: 'AutomationScheduleCapabilities' }, @@ -2928,7 +2980,7 @@ function checkExhaustiveness(project: Project): void { 'TerminalContentPart', 'MessageAttachment', 'MessageAttachmentBase', 'Customization', 'ChildCustomization', 'ChildCustomizationType', 'CustomizationLoadState', 'McpServerState', 'ToolCallContributor', - 'SessionOrigin', 'TerminalLifecycleState', 'AutomationTrigger', + 'SessionOrigin', 'WorkingDirectoryOrigin', 'TerminalLifecycleState', 'AutomationTrigger', 'AutomationDisableCondition', 'AutomationRunOrigin', 'AutomationRunLifecycle', 'SessionInputRequest', 'BackgroundWork', 'ToolCallConfirmationState', 'ToolCallRiskAssessment', diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 7ceb0a331..3e63b1136 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -160,6 +160,7 @@ function mapType(tsType: string): string { if (tsType === 'URI') return 'URI'; if (tsType === 'StringOrMarkdown') return 'StringOrMarkdown'; if (tsType === 'ToolInput') return 'ToolInput'; + if (tsType === 'URI | WorkingDirectory') return 'WorkingDirectoryEntry'; // ChildCustomizationType is a TS-only subset alias of CustomizationType. if (tsType === 'ChildCustomizationType') return 'CustomizationType'; @@ -758,7 +759,7 @@ const STATE_ENUMS = [ 'BackgroundWorkKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetFileStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', - 'SessionOriginKind', + 'SessionOriginKind', 'WorkingDirectoryOriginKind', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationDisableConditionKind', 'AutomationRunStatus', 'AutomationRunOriginKind', @@ -776,6 +777,10 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'AgentCapabilities' }, { name: 'MultipleChatsCapability' }, { name: 'MultipleWorkingDirectoriesCapability' }, + { name: 'WorkingDirectory' }, + { name: 'LocalWorkingDirectoryOrigin' }, + { name: 'RepoWorkingDirectoryOrigin' }, + { name: 'WorktreeWorkingDirectoryOrigin' }, { name: 'SessionModelInfo' }, { name: 'ModelSelection' }, { name: 'AgentSelection' }, @@ -1180,6 +1185,19 @@ const BACKGROUND_WORK_UNION: UnionConfig = { unknown: true, }; +const WORKING_DIRECTORY_ORIGIN_UNION: UnionConfig = { + name: 'WorkingDirectoryOrigin', + discriminantField: 'kind', + doc: 'WorkingDirectoryOrigin is the host-reported working-directory provenance.', + variants: [ + { variantName: 'Local', innerType: 'LocalWorkingDirectoryOrigin', wireValue: 'local' }, + { variantName: 'Repo', innerType: 'RepoWorkingDirectoryOrigin', wireValue: 'repo' }, + { variantName: 'Worktree', innerType: 'WorktreeWorkingDirectoryOrigin', wireValue: 'worktree' }, + ], + unknown: true, + injectDiscriminantOnMarshal: true, +}; + const SESSION_ORIGIN_UNION: UnionConfig = { name: 'SessionOrigin', discriminantField: 'kind', @@ -1482,6 +1500,52 @@ func containsAll(m map[string]json.RawMessage, keys ...string) bool { }`; } +function generateWorkingDirectoryEntry(): string { + return `// WorkingDirectoryEntry is a legacy URI or a complete working-directory record. +type WorkingDirectoryEntry struct { +\tURI *URI +\tDirectory *WorkingDirectory +} + +func (d WorkingDirectoryEntry) GetURI() URI { +\tif d.Directory != nil { +\t\treturn d.Directory.Uri +\t} +\tif d.URI != nil { +\t\treturn *d.URI +\t} +\treturn "" +} + +func (d WorkingDirectoryEntry) MarshalJSON() ([]byte, error) { +\tif d.Directory != nil { +\t\treturn json.Marshal(d.Directory) +\t} +\tif d.URI != nil { +\t\treturn json.Marshal(*d.URI) +\t} +\treturn nil, fmt.Errorf("WorkingDirectoryEntry requires a URI or directory") +} + +func (d *WorkingDirectoryEntry) UnmarshalJSON(data []byte) error { +\t*d = WorkingDirectoryEntry{} +\tvar uri *URI +\tif err := json.Unmarshal(data, &uri); err == nil && uri != nil { +\t\td.URI = uri +\t\treturn nil +\t} +\tvar directory *WorkingDirectory +\tif err := json.Unmarshal(data, &directory); err != nil { +\t\treturn err +\t} +\tif directory == nil { +\t\treturn fmt.Errorf("WorkingDirectoryEntry requires a URI or directory") +\t} +\td.Directory = directory +\treturn nil +}`; +} + function generateToolInput(): string { return `// ToolInput is raw tool input represented inline or by content reference. type ToolInput struct { @@ -1516,7 +1580,7 @@ func (t *ToolInput) UnmarshalJSON(data []byte) error { } function generateStateFile(project: Project): string { - const lines: string[] = [HEADER_WITH_IMPORTS]; + const lines: string[] = [GENERATED_BANNER, '\nimport (\n\t"encoding/json"\n\t"fmt"\n)\n']; lines.push('// ─── Enums ────────────────────────────────────────────────────────────\n'); for (const enumName of STATE_ENUMS) { @@ -1549,6 +1613,8 @@ function generateStateFile(project: Project): string { lines.push(generateToolInput()); lines.push(''); + lines.push(generateWorkingDirectoryEntry()); + lines.push(''); lines.push('// ─── Discriminated Unions ─────────────────────────────────────────────\n'); lines.push(generateDiscriminatedUnion(project, RESPONSE_PART_UNION)); @@ -1590,6 +1656,8 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(project, SESSION_ORIGIN_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, WORKING_DIRECTORY_ORIGIN_UNION)); + lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_TRIGGER_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_DISABLE_CONDITION_UNION)); @@ -1825,8 +1893,10 @@ function generateActionsFile(project: Project): string { const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ChatMoveDestinationKind', 'PathStyle', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode', 'AuthenticateTargetKind']; const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: string }[] = [ + { name: 'RepositorySource' }, { name: 'CreateTcpConnectionParams' }, { name: 'CreateTcpConnectionResult' }, { name: 'InitializeParams' }, { name: 'HostEnvironment' }, { name: 'InitializeResult' }, + { name: 'RepositoryPreparationCapability' }, { name: 'WorkingDirectoryInfoCapability' }, { name: 'ClientCapabilities' }, { name: 'AutomationCapabilities' }, { name: 'AutomationCreateCapability' }, { name: 'AutomationScheduleCapabilities' }, @@ -1892,6 +1962,7 @@ const RECONNECT_RESULT_UNION: UnionConfig = { { variantName: 'Replay', innerType: 'ReconnectReplayResult', wireValue: 'replay' }, { variantName: 'Snapshot', innerType: 'ReconnectSnapshotResult', wireValue: 'snapshot' }, ], + injectDiscriminantOnMarshal: true, }; const CHAT_SOURCE_UNION: UnionConfig = { @@ -2558,6 +2629,7 @@ function checkExhaustiveness(project: Project): void { 'ToolCallConfirmationState', 'ReconnectResult', 'SessionOrigin', + 'WorkingDirectoryOrigin', 'AutomationTrigger', 'AutomationDisableCondition', 'AutomationRunOrigin', diff --git a/scripts/generate-json-schema.test.ts b/scripts/generate-json-schema.test.ts index 6cd94baa3..4d67fbcaa 100644 --- a/scripts/generate-json-schema.test.ts +++ b/scripts/generate-json-schema.test.ts @@ -398,6 +398,69 @@ describe('generated JSON schemas', () => { assert.match(expiresIn.description as string, /MUST be a positive integer/); }); + it('keeps session config schema generic without repository metadata', () => { + const defs = schema.$defs as Record>; + const configSchema = defs.SessionConfigSchema; + const properties = configSchema.properties as Record>; + assert.deepEqual({ + fields: Object.keys(properties).sort(), + required: configSchema.required, + propertySchema: properties.properties.additionalProperties, + repositoryType: defs.RepositorySessionConfig, + }, { + fields: ['properties', 'required', 'type'], + required: ['type', 'properties'], + propertySchema: { $ref: '#/$defs/SessionConfigPropertySchema' }, + repositoryType: undefined, + }); + + const legacy = { + type: 'object', + properties: { mode: { type: 'string', title: 'Mode' } }, + required: ['mode'], + }; + assert.equal(schemaAccepts(schema, configSchema, legacy), true); + }); + + it('declares optional repository lists beside generic config', () => { + if (file !== 'commands.schema.json') { + return; + } + const defs = schema.$defs as Record>; + for (const [definition, channel] of [ + ['ResolveSessionConfigParams', 'ahp-root://'], + ['SessionConfigCompletionsParams', 'ahp-root://'], + ['CreateSessionParams', 'ahp-session:/repository-test'], + ]) { + const properties = defs[definition].properties as Record>; + assert.equal(properties.config.type, 'object'); + assert.deepEqual( + Object.keys(properties).filter(name => name.startsWith('repositor')), + ['repositories'], + ); + const base = { channel, ...(definition === 'SessionConfigCompletionsParams' ? { property: 'mode' } : {}) }; + assert.equal(schemaAccepts(schema, defs[definition], base), true); + const source = 'https://example.org/team/project.git'; + for (const repositories of [ + [], + [{ source }], + [{ source, revision: 'refs/tags/v1.2.3' }], + [{ source, subdirectory: 'packages/api' }], + [{ source, revision: 'main' }, { source, revision: 'feature' }], + ]) { + assert.equal(schemaAccepts(schema, defs[definition], { ...base, repositories, config: { mode: 'review' } }), true); + } + for (const repositories of [ + null, {}, source, [null], [source], [{}], + [{ revision: 'main' }], [{ source: 42 }], [{ source: null }], + [{ source, revision: 42 }], + [{ source, subdirectory: 42 }], + ]) { + assert.equal(schemaAccepts(schema, defs[definition], { ...base, repositories }), false); + } + } + }); + it('accepts an optional account-selection hint without requiring it for authentication', () => { if (file !== 'commands.schema.json') { return; @@ -436,6 +499,117 @@ describe('generated JSON schemas', () => { } }); + it('declares URI-keyed directory records instead of repository input metadata', () => { + const defs = schema.$defs as Record>; + for (const name of ['SessionMetadata', 'SessionState', 'SessionSummary']) { + const properties = defs[name].properties as Record>; + assert.equal(properties.repositories, undefined); + assert.equal(properties.workingDirectories.type, 'array'); + const item = properties.workingDirectories.items as JsonNode; + const uri = 'file:///work/project/packages/api'; + for (const directory of [ + uri, { uri }, { uri, repo: 'https://example.org/team/project.git' }, + { uri, origin: { kind: 'local' } }, + { uri, origin: { kind: 'repo' } }, + { uri, origin: { kind: 'worktree', mainWorktree: 'file:///work/project' } }, + ]) { + assert.equal(schemaAccepts(schema, item, directory), true); + } + for (const directory of [ + 42, null, {}, { repo: 'https://example.org/team/project.git' }, + { uri: 42 }, { uri, repo: 42 }, + { uri, origin: { kind: 'worktree' } }, + { uri, origin: { kind: 'worktree', mainWorktree: 42 } }, + ]) { + assert.equal(schemaAccepts(schema, item, directory), false); + } + } + if (file !== 'commands.schema.json') { + return; + } + const source = defs.RepositorySource; + const properties = source.properties as Record>; + assert.deepEqual({ + fields: Object.keys(properties), + required: source.required, + sourceType: dereferenceSchema(schema, properties.source).type, + revisionType: properties.revision.type, + subdirectoryType: properties.subdirectory.type, + }, { + fields: ['source', 'revision', 'subdirectory'], required: ['source'], + sourceType: 'string', revisionType: 'string', subdirectoryType: 'string', + }); + }); + + it('gates rich directories with a presence-only client capability', () => { + if (file !== 'commands.schema.json') { + return; + } + const defs = schema.$defs as Record>; + const capabilities = defs.ClientCapabilities; + const properties = capabilities.properties as Record>; + assert.equal(properties.workingDirectoryInfo.$ref, '#/$defs/WorkingDirectoryInfoCapability'); + for (const value of [{}, { workingDirectoryInfo: {} }, { mcpApps: {}, workingDirectoryInfo: {} }]) { + assert.equal(schemaAccepts(schema, capabilities, value), true); + } + for (const value of [true, null, []]) { + assert.equal(schemaAccepts(schema, capabilities, { workingDirectoryInfo: value }), false); + } + }); + + it('carries complete keyed metadata in set and replace actions', () => { + if (file !== 'actions.schema.json') { + return; + } + const defs = schema.$defs as Record>; + const directory = { uri: 'file:///work/tree', origin: { kind: 'worktree', mainWorktree: 'file:///work/main' } }; + for (const [name, action] of [ + ['SessionWorkingDirectorySetAction', { type: 'session/workingDirectorySet', directory }], + ['SessionWorkingDirectoryReplacedAction', { type: 'session/workingDirectoryReplaced', directory: 'file:///work/old', replacement: directory }], + ] as const) { + assert.equal(schemaAccepts(schema, defs[name], action), true); + const properties = defs[name].properties as Record>; + assert.equal(properties.origin, undefined); + } + assert.equal(schemaAccepts(schema, defs.SessionWorkingDirectoryRemovedAction, { + type: 'session/workingDirectoryRemoved', directory, + }), false); + assert.equal(schemaAccepts(schema, defs.SessionWorkingDirectoryReplacedAction, { + type: 'session/workingDirectoryReplaced', directory, replacement: 'file:///work/new', + }), false); + }); + + it('advertises repository preparation on the host, not the agent', () => { + const defs = schema.$defs as Record>; + const capabilities = defs.AgentCapabilities.properties as Record>; + assert.deepEqual({ + agentSource: capabilities.repositorySource, + agentPreparation: capabilities.repositoryPreparation, + oldCapability: defs.RepositorySourceCapability, + }, { + agentSource: undefined, agentPreparation: undefined, oldCapability: undefined, + }); + if (file !== 'commands.schema.json') { + return; + } + const properties = defs.InitializeResult.properties as Record>; + assert.equal(properties.repositoryPreparation.$ref, '#/$defs/RepositoryPreparationCapability'); + const hostCapabilities = defs.RepositoryPreparationCapability; + assert.equal(hostCapabilities.required, undefined); + const base = { protocolVersion: '0.9.0', serverSeq: 0, snapshots: [] }; + assert.equal(schemaAccepts(schema, defs.InitializeResult, base), true); + for (const repositoryPreparation of [ + {}, { revision: true }, { revision: false }, + { multipleRepositories: false }, { multipleRepositories: true }, + { revision: true, multipleRepositories: true }, + ]) { + assert.equal(schemaAccepts(schema, defs.InitializeResult, { ...base, repositoryPreparation }), true); + } + for (const repositoryPreparation of [true, null, [], { revision: 'true' }, { multipleRepositories: 2 }]) { + assert.equal(schemaAccepts(schema, defs.InitializeResult, { ...base, repositoryPreparation }), false); + } + }); + it('rejects duplicate disable-condition kinds, including different values of the same kind', () => { const defs = schema.$defs as Record>; const afterRuns = { kind: 'afterRuns', max: 3 }; diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 01219a4c7..9c9cd560f 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -146,6 +146,7 @@ function mapType(tsType: string): string { if (tsType === 'URI') return 'String'; if (tsType === 'StringOrMarkdown') return 'StringOrMarkdown'; if (tsType === 'ToolInput') return 'ToolInput'; + if (tsType === 'URI | WorkingDirectory') return 'WorkingDirectoryEntry'; // ChildCustomizationType is a TS-only subset alias of CustomizationType. if (tsType === 'ChildCustomizationType') return 'CustomizationType'; @@ -809,6 +810,52 @@ internal object StringOrMarkdownSerializer : KSerializer { }`; } +function generateWorkingDirectoryEntry(): string { + return `/** A legacy URI or a complete working-directory record. */ +@Serializable(with = WorkingDirectoryEntrySerializer::class) +sealed interface WorkingDirectoryEntry { + val uri: String + + data class Uri(val value: String) : WorkingDirectoryEntry { + override val uri: String get() = value + } + data class Directory(val value: WorkingDirectory) : WorkingDirectoryEntry { + override val uri: String get() = value.uri + } +} + +internal object WorkingDirectoryEntrySerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("WorkingDirectoryEntry") + + override fun deserialize(decoder: Decoder): WorkingDirectoryEntry { + val input = decoder as? JsonDecoder + ?: error("WorkingDirectoryEntry requires JSON") + return when (val element = input.decodeJsonElement()) { + is JsonPrimitive -> { + require(element.isString) { "WorkingDirectoryEntry URI must be a string" } + WorkingDirectoryEntry.Uri(element.content) + } + is JsonObject -> WorkingDirectoryEntry.Directory( + input.json.decodeFromJsonElement(WorkingDirectory.serializer(), element), + ) + else -> error("WorkingDirectoryEntry requires a URI or directory") + } + } + + override fun serialize(encoder: Encoder, value: WorkingDirectoryEntry) { + val output = encoder as? JsonEncoder + ?: error("WorkingDirectoryEntry requires JSON") + val element = when (value) { + is WorkingDirectoryEntry.Uri -> JsonPrimitive(value.value) + is WorkingDirectoryEntry.Directory -> + output.json.encodeToJsonElement(WorkingDirectory.serializer(), value.value) + } + output.encodeJsonElement(element) + } +}`; +} + function generateToolInput(): string { return `/** * Raw tool input represented inline or by content reference. @@ -1013,7 +1060,7 @@ const STATE_ENUMS = [ 'BackgroundWorkKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetFileStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', - 'SessionOriginKind', + 'SessionOriginKind', 'WorkingDirectoryOriginKind', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationDisableConditionKind', 'AutomationRunStatus', 'AutomationRunOriginKind', @@ -1027,6 +1074,7 @@ const STATE_STRUCTS = [ 'AgentCapabilities', 'MultipleChatsCapability', 'MultipleWorkingDirectoriesCapability', + 'WorkingDirectory', 'LocalWorkingDirectoryOrigin', 'RepoWorkingDirectoryOrigin', 'WorktreeWorkingDirectoryOrigin', 'SessionModelInfo', 'ModelSelection', 'AgentSelection', 'ConfigPropertySchema', 'ConfigSchema', 'PendingMessage', 'ChatState', 'ChatSummary', 'SideChatSelection', 'SessionState', 'SessionActiveClient', 'BackgroundShellWork', 'BackgroundSubagentWork', @@ -1375,6 +1423,18 @@ const BACKGROUND_WORK_UNION: UnionConfig = { unknown: true, }; +const WORKING_DIRECTORY_ORIGIN_UNION: UnionConfig = { + name: 'WorkingDirectoryOrigin', + discriminantField: 'kind', + variants: [ + { caseName: 'Local', structName: 'LocalWorkingDirectoryOrigin', discriminantValue: 'local' }, + { caseName: 'Repo', structName: 'RepoWorkingDirectoryOrigin', discriminantValue: 'repo' }, + { caseName: 'Worktree', structName: 'WorktreeWorkingDirectoryOrigin', discriminantValue: 'worktree' }, + ], + unknown: true, + injectDiscriminantOnSerialize: true, +}; + const SESSION_ORIGIN_UNION: UnionConfig = { name: 'SessionOrigin', discriminantField: 'kind', @@ -1477,6 +1537,8 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateToolInput()); lines.push(''); + lines.push(generateWorkingDirectoryEntry()); + lines.push(''); lines.push('// ─── Discriminated Unions ───────────────────────────────────────────────────'); lines.push(''); @@ -1519,6 +1581,8 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(project, SESSION_ORIGIN_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, WORKING_DIRECTORY_ORIGIN_UNION)); + lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_TRIGGER_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_DISABLE_CONDITION_UNION)); @@ -1805,8 +1869,10 @@ function generateActionsFile(project: Project): string { const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ChatMoveDestinationKind', 'PathStyle', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode', 'AuthenticateTargetKind']; const COMMAND_STRUCTS = [ + 'RepositorySource', 'CreateTcpConnectionParams', 'CreateTcpConnectionResult', 'InitializeParams', 'HostEnvironment', 'InitializeResult', + 'RepositoryPreparationCapability', 'WorkingDirectoryInfoCapability', 'ClientCapabilities', 'AutomationCapabilities', 'AutomationCreateCapability', 'AutomationScheduleCapabilities', @@ -2511,6 +2577,7 @@ function checkExhaustiveness(project: Project): void { 'JsonRpcErrorCode', // type-level alias over JsonRpcErrorCodes const enum 'ReconnectResult', // RECONNECT_RESULT_UNION discriminated union 'SessionOrigin', // SESSION_ORIGIN_UNION discriminated union + 'WorkingDirectoryOrigin', 'AutomationTrigger', // AUTOMATION_TRIGGER_UNION discriminated union 'AutomationDisableCondition', // AUTOMATION_DISABLE_CONDITION_UNION discriminated union 'AutomationRunOrigin', // AUTOMATION_RUN_ORIGIN_UNION discriminated union diff --git a/scripts/generate-markdown.test.ts b/scripts/generate-markdown.test.ts new file mode 100644 index 000000000..134b0bc4c --- /dev/null +++ b/scripts/generate-markdown.test.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { strict as assert } from 'node:assert'; +import { randomUUID } from 'node:crypto'; +import { readFileSync, rmSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { Project } from 'ts-morph'; +import { generateMarkdownDocs } from './generate-markdown.js'; + +test('command helper types have public reference anchors and fields', () => { + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + const output = join(root, 'node_modules', '.cache', `markdown-${randomUUID()}`); + try { + const project = new Project({ tsConfigFilePath: join(root, 'types', 'tsconfig.json') }); + generateMarkdownDocs(project, output); + const session = readFileSync(join(output, 'session.md'), 'utf8'); + const common = readFileSync(join(output, 'common.md'), 'utf8'); + assert.deepEqual({ + source: session.includes('### `RepositorySource`'), + subdirectory: session.includes('| `subdirectory` |'), + clientCapabilities: common.includes('### `ClientCapabilities`'), + workingDirectoryInfo: common.includes('| `workingDirectoryInfo` |'), + preparation: common.includes('### `RepositoryPreparationCapability`'), + workingDirectoryInfoCapability: common.includes('### `WorkingDirectoryInfoCapability`'), + }, { + source: true, + subdirectory: true, + clientCapabilities: true, + workingDirectoryInfo: true, + preparation: true, + workingDirectoryInfoCapability: true, + }); + } finally { + rmSync(output, { recursive: true, force: true }); + } +}); diff --git a/scripts/generate-markdown.ts b/scripts/generate-markdown.ts index f513924cc..e0fa16d05 100644 --- a/scripts/generate-markdown.ts +++ b/scripts/generate-markdown.ts @@ -647,6 +647,7 @@ function getActionTypeValue(iface: InterfaceDeclaration): string | undefined { * table, and a result table looked up from the registry. */ function emitCommandsSection(project: Project, sourceFiles: SourceFile[]): string { + const supportingTypes = new Set(['RepositorySource', 'RepositoryPreparationCapability', 'ClientCapabilities', 'WorkingDirectoryInfoCapability']); const commandMap = parseRegistryInterface(project, 'CommandMap', true); const methodByParams = new Map(); for (const entry of commandMap) methodByParams.set(entry.paramsType, entry); @@ -663,6 +664,10 @@ function emitCommandsSection(project: Project, sourceFiles: SourceFile[]): strin for (const stmt of sf.getStatements()) { if (!Node.isInterfaceDeclaration(stmt) || !stmt.isExported()) continue; const name = stmt.getName(); + if (supportingTypes.has(name)) { + lines.push(renderInterfaceBlock(stmt)); + continue; + } const entry = methodByParams.get(name); // Fallback: any *Params interface with a @method tag we missed. if (!entry) { diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index e90bd4a15..31f432492 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -155,6 +155,7 @@ function mapType(tsType: string, propName?: string, containerName?: string): str if (tsType === 'URI') return 'Uri'; if (tsType === 'StringOrMarkdown') return 'StringOrMarkdown'; if (tsType === 'ToolInput') return 'ToolInput'; + if (tsType === 'URI | WorkingDirectory') return 'WorkingDirectoryEntry'; // ChildCustomizationType is a TS-only subset alias of CustomizationType. if (tsType === 'ChildCustomizationType') return 'CustomizationType'; @@ -855,7 +856,7 @@ const STATE_ENUMS = [ 'BackgroundWorkKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetFileStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', - 'SessionOriginKind', + 'SessionOriginKind', 'WorkingDirectoryOriginKind', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationDisableConditionKind', 'AutomationRunStatus', 'AutomationRunOriginKind', @@ -878,6 +879,10 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'AgentCapabilities' }, { name: 'MultipleChatsCapability' }, { name: 'MultipleWorkingDirectoriesCapability' }, + { name: 'WorkingDirectory' }, + { name: 'LocalWorkingDirectoryOrigin', omitDiscriminants: true }, + { name: 'RepoWorkingDirectoryOrigin', omitDiscriminants: true }, + { name: 'WorktreeWorkingDirectoryOrigin', omitDiscriminants: true }, { name: 'SessionModelInfo' }, { name: 'ModelSelection' }, { name: 'AgentSelection' }, @@ -1287,6 +1292,18 @@ const BACKGROUND_WORK_UNION: UnionConfig = { unknown: true, }; +const WORKING_DIRECTORY_ORIGIN_UNION: UnionConfig = { + name: 'WorkingDirectoryOrigin', + discriminantField: 'kind', + doc: 'Host-reported working-directory provenance.', + variants: [ + { variantName: 'Local', innerType: 'LocalWorkingDirectoryOrigin', wireValue: 'local' }, + { variantName: 'Repo', innerType: 'RepoWorkingDirectoryOrigin', wireValue: 'repo' }, + { variantName: 'Worktree', innerType: 'WorktreeWorkingDirectoryOrigin', wireValue: 'worktree' }, + ], + unknown: true, +}; + const SESSION_ORIGIN_UNION: UnionConfig = { name: 'SessionOrigin', discriminantField: 'kind', @@ -1462,6 +1479,37 @@ impl<'de> Deserialize<'de> for Snapshot { }`; } +function generateWorkingDirectoryEntry(): string { + return `/// A legacy URI or a complete working-directory record. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum WorkingDirectoryEntry { + Uri(Uri), + Directory(WorkingDirectory), +} + +impl WorkingDirectoryEntry { + pub fn uri(&self) -> &Uri { + match self { + Self::Uri(uri) => uri, + Self::Directory(directory) => &directory.uri, + } + } +} + +impl From for WorkingDirectoryEntry { + fn from(uri: Uri) -> Self { + Self::Uri(uri) + } +} + +impl From for WorkingDirectoryEntry { + fn from(directory: WorkingDirectory) -> Self { + Self::Directory(directory) + } +}`; +} + function generateToolInput(): string { return `/// Raw tool input represented inline or by content reference. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -1540,6 +1588,8 @@ where D: serde::Deserializer<'de>, lines.push(generateToolInput()); lines.push(''); + lines.push(generateWorkingDirectoryEntry()); + lines.push(''); lines.push('// ─── Discriminated Unions ─────────────────────────────────────────────\n'); lines.push(generateChatOrigin(project)); @@ -1583,6 +1633,8 @@ where D: serde::Deserializer<'de>, lines.push(''); lines.push(generateDiscriminatedUnion(project, SESSION_ORIGIN_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, WORKING_DIRECTORY_ORIGIN_UNION)); + lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_TRIGGER_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_DISABLE_CONDITION_UNION)); @@ -1792,7 +1844,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, BackgroundWork, CanvasReference, CanvasState, ChangesSummary, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, FileEditCollection, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionConfigSchema, 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, CanvasReference, CanvasState, ChangesSummary, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, FileEditCollection, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionConfigSchema, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary, WorkingDirectoryEntry};'); lines.push(''); // ActionType enum lines.push('// ─── ActionType ──────────────────────────────────────────────────────\n'); @@ -1898,8 +1950,10 @@ pub struct ActionEnvelope { const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ChatMoveDestinationKind', 'PathStyle', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode', 'AuthenticateTargetKind']; const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: string }[] = [ + { name: 'RepositorySource' }, { name: 'CreateTcpConnectionParams' }, { name: 'CreateTcpConnectionResult' }, { name: 'InitializeParams' }, { name: 'HostEnvironment' }, { name: 'InitializeResult' }, + { name: 'RepositoryPreparationCapability' }, { name: 'WorkingDirectoryInfoCapability' }, { name: 'ClientCapabilities' }, { name: 'AutomationCapabilities' }, { name: 'AutomationCreateCapability' }, { name: 'AutomationScheduleCapabilities' }, @@ -2166,7 +2220,7 @@ const NOTIFICATION_STRUCTS = [ function generateNotificationsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentSelection, AnnotationsSummary, ChangesSummary, Changeset, FileEdit, ModelSelection, ProjectInfo, ProtectedResourceMetadata, SessionChatSummary, SessionOrigin, SessionStatus, SessionSummary, Snapshot};'); + lines.push('use crate::state::{AgentSelection, AnnotationsSummary, ChangesSummary, Changeset, FileEdit, ModelSelection, ProjectInfo, ProtectedResourceMetadata, SessionChatSummary, SessionOrigin, SessionStatus, SessionSummary, Snapshot, WorkingDirectoryEntry};'); lines.push(''); lines.push('// ─── Enums ────────────────────────────────────────────────────────────\n'); @@ -2535,6 +2589,7 @@ function checkExhaustiveness(project: Project): void { 'ToolCallConfirmationState', // TOOL_CALL_CONFIRMATION_STATE_UNION discriminated union 'ReconnectResult', 'SessionOrigin', + 'WorkingDirectoryOrigin', 'AutomationTrigger', 'AutomationDisableCondition', 'AutomationRunOrigin', diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 6209f1774..0211a3e4c 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -116,6 +116,7 @@ function mapType(tsType: string, propName?: string, containerName?: string): str if (tsType === 'URI') return 'String'; if (tsType === 'StringOrMarkdown') return 'StringOrMarkdown'; if (tsType === 'ToolInput') return 'ToolInput'; + if (tsType === 'URI | WorkingDirectory') return 'WorkingDirectoryEntry'; // ChildCustomizationType is a TS-only subset alias of CustomizationType. if (tsType === 'ChildCustomizationType') return 'CustomizationType'; @@ -736,7 +737,7 @@ const STATE_ENUMS = [ 'BackgroundWorkKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetFileStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', - 'SessionOriginKind', + 'SessionOriginKind', 'WorkingDirectoryOriginKind', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationDisableConditionKind', 'AutomationRunStatus', 'AutomationRunOriginKind', @@ -750,6 +751,7 @@ const STATE_STRUCTS = [ 'AgentCapabilities', 'MultipleChatsCapability', 'MultipleWorkingDirectoriesCapability', + 'WorkingDirectory', 'LocalWorkingDirectoryOrigin', 'RepoWorkingDirectoryOrigin', 'WorktreeWorkingDirectoryOrigin', 'SessionModelInfo', 'ModelSelection', 'AgentSelection', 'ConfigPropertySchema', 'ConfigSchema', 'PendingMessage', 'ChatState', 'ChatSummary', 'SideChatSelection', 'SessionState', 'SessionActiveClient', 'BackgroundShellWork', 'BackgroundSubagentWork', @@ -1138,6 +1140,38 @@ public enum StringOrMarkdown: Codable, Sendable, Equatable { }`; } +function generateWorkingDirectoryEntry(): string { + return `/// A legacy URI or a complete working-directory record. +public enum WorkingDirectoryEntry: Codable, Sendable { + case uri(String) + case directory(WorkingDirectory) + + public var uri: String { + switch self { + case .uri(let value): return value + case .directory(let value): return value.uri + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let uri = try? container.decode(String.self) { + self = .uri(uri) + } else { + self = .directory(try container.decode(WorkingDirectory.self)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .uri(let value): try container.encode(value) + case .directory(let value): try container.encode(value) + } + } +}`; +} + function generateToolInput(): string { return `/// Raw tool input represented inline or by content reference. public enum ToolInput: Codable, Sendable, Equatable { @@ -1298,6 +1332,18 @@ public enum ChatOrigin: Codable, Sendable, Equatable { }`; } +const WORKING_DIRECTORY_ORIGIN_UNION: UnionConfig = { + name: 'WorkingDirectoryOrigin', + discriminantField: 'kind', + variants: [ + { caseName: 'local', structName: 'LocalWorkingDirectoryOrigin', discriminantValue: 'local' }, + { caseName: 'repo', structName: 'RepoWorkingDirectoryOrigin', discriminantValue: 'repo' }, + { caseName: 'worktree', structName: 'WorktreeWorkingDirectoryOrigin', discriminantValue: 'worktree' }, + ], + unknown: true, + injectDiscriminantOnEncode: true, +}; + const SESSION_ORIGIN_UNION: UnionConfig = { name: 'SessionOrigin', discriminantField: 'kind', @@ -1388,6 +1434,8 @@ function generateStateFile(project: Project): string { lines.push('// MARK: - Tool Input\n'); lines.push(generateToolInput()); lines.push(''); + lines.push(generateWorkingDirectoryEntry()); + lines.push(''); lines.push('// MARK: - Discriminated Unions\n'); lines.push(generateChatOriginSwift()); @@ -1429,6 +1477,8 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(project, SESSION_ORIGIN_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, WORKING_DIRECTORY_ORIGIN_UNION)); + lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_TRIGGER_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_DISABLE_CONDITION_UNION)); @@ -1753,8 +1803,10 @@ public func isClientDispatchable(_ action: StateAction) -> Bool { const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ChatMoveDestinationKind', 'PathStyle', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode', 'AuthenticateTargetKind']; const COMMAND_STRUCTS = [ + 'RepositorySource', 'CreateTcpConnectionParams', 'CreateTcpConnectionResult', 'InitializeParams', 'HostEnvironment', 'InitializeResult', 'PingParams', 'ClientCapabilities', 'AutomationCapabilities', + 'RepositoryPreparationCapability', 'WorkingDirectoryInfoCapability', 'AutomationCreateCapability', 'AutomationScheduleCapabilities', 'AutomationRunCancellationCapability', @@ -2624,6 +2676,7 @@ function checkExhaustiveness(project: Project): void { 'JsonRpcErrorCode', // type-level alias over JsonRpcErrorCodes const enum 'ReconnectResult', // RECONNECT_RESULT_UNION discriminated union 'SessionOrigin', // SESSION_ORIGIN_UNION discriminated union + 'WorkingDirectoryOrigin', 'AutomationTrigger', // AUTOMATION_TRIGGER_UNION discriminated union 'AutomationDisableCondition', // AUTOMATION_DISABLE_CONDITION_UNION discriminated union 'AutomationRunOrigin', // AUTOMATION_RUN_ORIGIN_UNION discriminated union diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index c8f076508..a8098bb7a 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -634,8 +634,9 @@ export interface ChatCanvasesChangedAction { * Membership semantics keyed by the directory URI: the reducer appends * `directory` when the chat's subset does not already contain it (creating the * subset if absent) and is a no-op when it is already present. `directory` MUST - * be one of the owning session's {@link SessionState.workingDirectories}; a host - * MUST reject a directory that is not. Only valid when the agent advertises + * match a URI string or record's `uri` in the owning session's + * {@link SessionState.workingDirectories}; a host MUST reject a directory that + * does not. Only valid when the agent advertises * {@link AgentCapabilities.multipleWorkingDirectories}. * * @category Chat Actions diff --git a/types/channels-chat/commands.ts b/types/channels-chat/commands.ts index 846b3db57..dda889234 100644 --- a/types/channels-chat/commands.ts +++ b/types/channels-chat/commands.ts @@ -108,9 +108,9 @@ export interface CreateChatParams extends BaseParams { */ source?: ChatSource; /** - * Initial working-directory subset for this chat. Every entry MUST be - * present in the owning session's `workingDirectories`; the server MUST - * reject any entry that is not. When absent, the chat inherits the full + * Initial working-directory URI subset for this chat. Every URI MUST match + * a URI string or record's `uri` in the owning session's `workingDirectories`; + * the server MUST reject any entry that does not. When absent, the chat inherits the full * session set. Forked chats (those whose `source.kind` is `"fork"`) inherit * the source chat's `workingDirectories`; this field is ignored for forks. * diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index 40e517a0e..64c7a28b4 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -96,13 +96,14 @@ export interface ChatState { /** * The subset of the session's * {@link SessionState.workingDirectories | `workingDirectories`} that this - * chat's agent has tool access to. Every entry MUST be present in the owning - * session's `workingDirectories`; servers MUST reject a + * chat's agent has tool access to. Every URI MUST match a URI string or a + * record's `uri` in the owning session's set; servers MUST reject a * `chat/workingDirectorySet` action that violates this constraint. * * When absent, the chat inherits the full session set. When present but empty * (not recommended), the chat has no working-directory tool access at all. * + * Directory metadata belongs to the session, not the chat. * Dispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to * update the subset on a running chat. */ diff --git a/types/channels-root/commands.ts b/types/channels-root/commands.ts index ff3c706b7..e312664ae 100644 --- a/types/channels-root/commands.ts +++ b/types/channels-root/commands.ts @@ -9,6 +9,7 @@ import type { URI } from '../common/state.js'; import type { BaseParams, PaginatedParams, PaginatedResult } from '../common/commands.js'; import type { SessionSummary, SessionConfigSchema } from '../channels-session/state.js'; +import type { RepositorySource } from '../channels-session/commands.js'; // Re-export schema types so the legacy `commands.ts` aggregator continues to // expose them from the same import path. @@ -130,6 +131,8 @@ export interface ResolveSessionConfigParams extends BaseParams { provider?: string; /** Working directory for the session */ workingDirectory?: URI; + /** Repositories used as configuration context; an empty list is equivalent to omission. */ + repositories?: RepositorySource[]; /** Current user-filled configuration values */ config?: Record; } @@ -195,6 +198,8 @@ export interface SessionConfigCompletionsParams extends BaseParams { provider?: string; /** Working directory for the session */ workingDirectory?: URI; + /** Repositories used as configuration context; an empty list is equivalent to omission. */ + repositories?: RepositorySource[]; /** Current user-filled configuration values (provides context for the query) */ config?: Record; /** Property id from the schema to query values for */ diff --git a/types/channels-session/actions.ts b/types/channels-session/actions.ts index fbe89fa22..edb5621f8 100644 --- a/types/channels-session/actions.ts +++ b/types/channels-session/actions.ts @@ -13,6 +13,7 @@ import type { Customization, CustomizationEnablement, McpServerState, + WorkingDirectory, } from './state.js'; import type { URI } from '../common/state.js'; import type { Changeset } from '../channels-changeset/state.js'; @@ -283,13 +284,14 @@ export interface SessionActiveClientRemovedAction { // ─── Working Directory Actions ─────────────────────────────────────────────── /** - * A working directory was added to the session's + * A working directory was added or updated in the session's * {@link SessionState.workingDirectories} set. * - * Membership semantics keyed by the directory URI: the reducer appends - * `directory` when the set does not already contain it (creating the set if - * absent) and is a no-op when it is already present. Only valid when the agent - * advertises {@link AgentCapabilities.multipleWorkingDirectories}. + * Upsert keyed by URI. A rich record replaces the existing entry in place; + * a URI-only duplicate preserves known metadata. Only valid when the agent + * advertises {@link AgentCapabilities.multipleWorkingDirectories}. Clients + * MUST omit `repo` and use only local or absent `origin`; the host validates + * and enriches the entry before accepting it. * * @category Session Actions * @version 1 @@ -298,7 +300,7 @@ export interface SessionActiveClientRemovedAction { export interface SessionWorkingDirectorySetAction { type: ActionType.SessionWorkingDirectorySet; /** The working directory to grant the session's agent tool access to. */ - directory: URI; + directory: URI | WorkingDirectory; } /** @@ -336,6 +338,8 @@ export interface SessionWorkingDirectoryRemovedAction { * for example, `[A, B, C]` with `B → C` becomes `[A, C]`. When it occurs * before the target, it retains its earlier position and the target is removed; * `[A, B, C]` with `C → A` becomes `[A, B]`. + * A rich replacement supplies the complete entry; a URI-only replacement + * preserves metadata already known for that URI. * * Only valid when the agent advertises * {@link AgentCapabilities.multipleWorkingDirectories}. Replacing index `0` @@ -343,6 +347,7 @@ export interface SessionWorkingDirectoryRemovedAction { * {@link MultipleWorkingDirectoriesCapability.primaryReplacement}; clients * MUST NOT target an immutable primary. The host MUST validate and apply its * backend side effect before broadcasting an accepted action, or reject it. + * Clients MUST omit `repo` and use only local or absent `origin`. * * @category Session Actions * @version 1 @@ -352,8 +357,8 @@ export interface SessionWorkingDirectoryReplacedAction { type: ActionType.SessionWorkingDirectoryReplaced; /** URI of the existing entry to replace. */ directory: URI; - /** URI to place in the replaced entry's position. */ - replacement: URI; + /** Complete entry to place in the replaced entry's position. */ + replacement: URI | WorkingDirectory; } // ─── Input Needed Actions ──────────────────────────────────────────────────── diff --git a/types/channels-session/commands.ts b/types/channels-session/commands.ts index 5c452067b..4577880e4 100644 --- a/types/channels-session/commands.ts +++ b/types/channels-session/commands.ts @@ -16,6 +16,20 @@ import type { // ─── createSession ─────────────────────────────────────────────────────────── +/** + * Requested repository source, not a resolved working directory. + * + * @category Commands + */ +export interface RepositorySource { + /** Credential-free repository source URI. */ + source: URI; + /** Requested branch, tag, or commit. Omit to use the host's default revision. */ + revision?: string; + /** Repository-relative selected folder; omit for the root. Hosts reject empty, absolute, or escaping paths. */ + subdirectory?: string; +} + /** * Creates a new session with the specified agent provider. * @@ -66,9 +80,13 @@ export interface CreateSessionParams extends BaseParams { * capability treats only the first entry as the session's working directory * and ignores the rest. Dispatch working-directory actions to change the set * after the session has started. - * */ workingDirectories?: URI[]; + /** + * Repositories to prepare instead of an explicit `workingDirectories` list. + * An empty list is equivalent to omission. + */ + repositories?: RepositorySource[]; /** * Agent-specific configuration values collected via `resolveSessionConfig`. * Keys and values correspond to the schema returned by the server. diff --git a/types/channels-session/reducer.ts b/types/channels-session/reducer.ts index 8de046dd4..dd79665fc 100644 --- a/types/channels-session/reducer.ts +++ b/types/channels-session/reducer.ts @@ -12,7 +12,9 @@ import type { Customization, CustomizationEnablement, McpServerCustomization, + WorkingDirectory, } from './state.js'; +import type { URI } from '../common/state.js'; import { SessionLifecycle, SessionStatus, @@ -25,6 +27,10 @@ import { softAssertNever } from '../common/reducer-helpers.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── +function workingDirectoryUri(directory: URI | WorkingDirectory): URI { + return typeof directory === 'string' ? directory : directory.uri; +} + /** Bitmask covering the mutually-exclusive activity bits (bits 0–4). */ const STATUS_ACTIVITY_MASK = (1 << 5) - 1; @@ -322,10 +328,17 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: case ActionType.SessionWorkingDirectorySet: { const list = state.workingDirectories ?? []; - if (list.includes(action.directory)) { + const uri = workingDirectoryUri(action.directory); + const idx = list.findIndex(directory => workingDirectoryUri(directory) === uri); + if (idx < 0) { + return { ...state, workingDirectories: [...list, action.directory] }; + } + if (typeof action.directory === 'string') { return state; } - return { ...state, workingDirectories: [...list, action.directory] }; + const updated = list.slice(); + updated[idx] = action.directory; + return { ...state, workingDirectories: updated }; } case ActionType.SessionWorkingDirectoryRemoved: { @@ -333,7 +346,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: if (!list) { return state; } - const idx = list.indexOf(action.directory); + const idx = list.findIndex(directory => workingDirectoryUri(directory) === action.directory); if (idx < 0) { return state; } @@ -347,23 +360,26 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: if (!list) { return state; } - const idx = list.indexOf(action.directory); + const idx = list.findIndex(directory => workingDirectoryUri(directory) === action.directory); if (idx < 0) { return state; } - const replacementIdx = list.indexOf(action.replacement); + const uri = workingDirectoryUri(action.replacement); + const replacementIdx = list.findIndex(directory => workingDirectoryUri(directory) === uri); + const replacement = typeof action.replacement === 'string' && replacementIdx >= 0 + ? list[replacementIdx] + : action.replacement; + const updated = list.slice(); if (replacementIdx >= 0 && replacementIdx < idx) { - return { - ...state, - workingDirectories: list.filter((_, index) => index !== idx), - }; + updated[replacementIdx] = replacement; + updated.splice(idx, 1); + } else { + updated[idx] = replacement; + if (replacementIdx > idx) { + updated.splice(replacementIdx, 1); + } } - return { - ...state, - workingDirectories: list - .map((directory, index) => (index === idx ? action.replacement : directory)) - .filter((directory, index) => index === idx || directory !== action.replacement), - }; + return { ...state, workingDirectories: updated }; } // ── Input Needed ──────────────────────────────────────────────────── diff --git a/types/channels-session/state.ts b/types/channels-session/state.ts index 8b437edb4..f0ce352f6 100644 --- a/types/channels-session/state.ts +++ b/types/channels-session/state.ts @@ -103,6 +103,59 @@ export interface AutomationSessionOrigin { */ export type SessionOrigin = AutomationSessionOrigin; +/** + * How a working directory was prepared. + * + * @category Session State + * @nonexhaustive + */ +export const enum WorkingDirectoryOriginKind { + Local = 'local', + Repo = 'repo', + Worktree = 'worktree', +} + +/** @category Session State */ +export interface LocalWorkingDirectoryOrigin { + kind: WorkingDirectoryOriginKind.Local; +} + +/** @category Session State */ +export interface RepoWorkingDirectoryOrigin { + kind: WorkingDirectoryOriginKind.Repo; +} + +/** @category Session State */ +export interface WorktreeWorkingDirectoryOrigin { + kind: WorkingDirectoryOriginKind.Worktree; + /** Main worktree associated with the host-prepared worktree. */ + mainWorktree: URI; +} + +/** + * Host-reported preparation result, independent of the creation input. + * + * @category Session State + */ +export type WorkingDirectoryOrigin = + | LocalWorkingDirectoryOrigin + | RepoWorkingDirectoryOrigin + | WorktreeWorkingDirectoryOrigin; + +/** + * An actual working directory, uniquely keyed by `uri` within the session. + * + * @category Session State + */ +export interface WorkingDirectory { + /** Actual selected directory, which may be a repository subdirectory. */ + uri: URI; + /** Credential-free repository source association, not a checkout identity. */ + repo?: URI; + /** Host-reported provenance; omission means unspecified. */ + origin?: WorkingDirectoryOrigin; +} + /** * Metadata shared between the full {@link SessionState} (delivered when a * client subscribes to a session's URI) and the lightweight @@ -140,8 +193,11 @@ export interface SessionMetadata { * MAY restrict to a subset via * {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a * chat that sets none operates against this full set. + * Entries are uniquely keyed by URI. Rich records require + * {@link ClientCapabilities.workingDirectoryInfo}; other clients receive URIs. */ - workingDirectories?: URI[]; + workingDirectories?: (URI | WorkingDirectory)[]; + /** * Lightweight summary of this session's inline annotations channel * (`ahp-annotations:/`). Surfaced so badge UI can render diff --git a/types/common/commands.ts b/types/common/commands.ts index 385d7f888..b447e9440 100644 --- a/types/common/commands.ts +++ b/types/common/commands.ts @@ -215,6 +215,11 @@ export interface InitializeParams extends BaseParams { * @category Commands */ export interface ClientCapabilities { + /** + * Client accepts rich {@link WorkingDirectory} records as well as URI strings. + * Hosts project records to URIs when absent and retain this choice on reconnect. + */ + workingDirectoryInfo?: WorkingDirectoryInfoCapability; /** * Client can render * [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) — i.e. @@ -231,6 +236,13 @@ export interface ClientCapabilities { mcpApps?: Record; } +/** + * Presence capability for {@link ClientCapabilities.workingDirectoryInfo}. + * + * @category Commands + */ +export interface WorkingDirectoryInfoCapability {} + /** * Native filesystem path grammar used by a host environment. * @@ -314,6 +326,8 @@ export interface InitializeResult { environment?: HostEnvironment; /** Suggested default directory for remote filesystem browsing */ defaultDirectory?: URI; + /** Host repository preparation support; absent when unsupported. */ + repositoryPreparation?: RepositoryPreparationCapability; /** * Characters that, when typed in a {@link Message} input, SHOULD cause * the client to issue a `completions` request with @@ -372,6 +386,19 @@ export interface InitializeResult { */ export interface MultipleChatProvidersCapability {} +/** + * Host support for repository preparation at session creation. Presence + * alone supports one repository at its default revision. + * + * @category Commands + */ +export interface RepositoryPreparationCapability { + /** When true, clients may supply {@link RepositorySource.revision}. */ + revision?: boolean; + /** When true, clients may supply more than one repository. */ + multipleRepositories?: boolean; +} + /** * Automation features supported by this host authority. * diff --git a/types/test-cases/reducers/272-directory-provenance-survives-ready-and-config.json b/types/test-cases/reducers/272-directory-provenance-survives-ready-and-config.json new file mode 100644 index 000000000..c3e3017ca --- /dev/null +++ b/types/test-cases/reducers/272-directory-provenance-survives-ready-and-config.json @@ -0,0 +1,48 @@ +{ + "description": "Resolved directory provenance survives readiness and config changes without echoing creation inputs", + "reducer": "session", + "initial": { + "provider": "example", + "title": "Repository session", + "status": 1, + "lifecycle": "creating", + "activeClients": [], + "chats": [], + "config": { + "schema": { "type": "object", "properties": { "mode": { "type": "string", "title": "Mode" } } }, + "values": { "mode": "review" } + } + }, + "actions": [ + { "type": "session/workingDirectorySet", "directory": { + "uri": "file:///work/project", + "repo": "https://example.org/team/project.git", + "origin": { "kind": "repo" } + } }, + { "type": "session/ready" }, + { "type": "session/configChanged", "config": { "mode": "interactive" }, "replace": true }, + { "type": "session/workingDirectorySet", "directory": { + "uri": "file:///work/project-worktree/packages/api", + "repo": "https://example.org/team/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///work/project" } + } }, + { "type": "session/workingDirectoryRemoved", "directory": "file:///work/project" } + ], + "expected": { + "provider": "example", + "title": "Repository session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": [{ + "uri": "file:///work/project-worktree/packages/api", + "repo": "https://example.org/team/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///work/project" } + }], + "config": { + "schema": { "type": "object", "properties": { "mode": { "type": "string", "title": "Mode" } } }, + "values": { "mode": "interactive" } + } + } +} diff --git a/types/test-cases/reducers/273-repository-preparation-failure-no-directories.json b/types/test-cases/reducers/273-repository-preparation-failure-no-directories.json new file mode 100644 index 000000000..1f886eec3 --- /dev/null +++ b/types/test-cases/reducers/273-repository-preparation-failure-no-directories.json @@ -0,0 +1,24 @@ +{ + "description": "Failed preparation does not fabricate directories or echo repository inputs", + "reducer": "session", + "initial": { + "provider": "example", + "title": "Repository session", + "status": 1, + "lifecycle": "creating", + "activeClients": [], + "chats": [] + }, + "actions": [ + { "type": "session/creationFailed", "error": { "errorType": "preparationFailed", "message": "Preparation failed" } } + ], + "expected": { + "provider": "example", + "title": "Repository session", + "status": 1, + "lifecycle": "failed", + "creationError": { "errorType": "preparationFailed", "message": "Preparation failed" }, + "activeClients": [], + "chats": [] + } +} diff --git a/types/test-cases/reducers/274-rich-directory-set-inserts.json b/types/test-cases/reducers/274-rich-directory-set-inserts.json new file mode 100644 index 000000000..254d88c74 --- /dev/null +++ b/types/test-cases/reducers/274-rich-directory-set-inserts.json @@ -0,0 +1,23 @@ +{ + "description": "Rich directory set creates the set and retains complete provenance", + "reducer": "session", + "initial": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [] + }, + "actions": [{ + "type": "session/workingDirectorySet", + "directory": { + "uri": "file:///work/tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///work/main" } + } + }], + "expected": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [{ + "uri": "file:///work/tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///work/main" } + }] + } +} diff --git a/types/test-cases/reducers/275-rich-directory-set-upgrades-uri.json b/types/test-cases/reducers/275-rich-directory-set-upgrades-uri.json new file mode 100644 index 000000000..0ec891224 --- /dev/null +++ b/types/test-cases/reducers/275-rich-directory-set-upgrades-uri.json @@ -0,0 +1,22 @@ +{ + "description": "Rich same-URI set upgrades a legacy entry without changing order", + "reducer": "session", + "initial": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///a", "file:///b", { "uri": "file:///c", "origin": { "kind": "local" } }] + }, + "actions": [{ + "type": "session/workingDirectorySet", + "directory": { "uri": "file:///b", "repo": "https://example.org/project.git", "origin": { "kind": "repo" } } + }], + "expected": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [ + "file:///a", + { "uri": "file:///b", "repo": "https://example.org/project.git", "origin": { "kind": "repo" } }, + { "uri": "file:///c", "origin": { "kind": "local" } } + ] + } +} diff --git a/types/test-cases/reducers/276-rich-directory-set-replaces-complete-record.json b/types/test-cases/reducers/276-rich-directory-set-replaces-complete-record.json new file mode 100644 index 000000000..26ed8e871 --- /dev/null +++ b/types/test-cases/reducers/276-rich-directory-set-replaces-complete-record.json @@ -0,0 +1,18 @@ +{ + "description": "Rich same-URI set replaces the whole record rather than merging stale metadata", + "reducer": "session", + "initial": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [{ + "uri": "file:///a", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } + }, "file:///b"] + }, + "actions": [{ "type": "session/workingDirectorySet", "directory": { "uri": "file:///a" } }], + "expected": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [{ "uri": "file:///a" }, "file:///b"] + } +} diff --git a/types/test-cases/reducers/277-uri-directory-set-preserves-record.json b/types/test-cases/reducers/277-uri-directory-set-preserves-record.json new file mode 100644 index 000000000..2c57699cd --- /dev/null +++ b/types/test-cases/reducers/277-uri-directory-set-preserves-record.json @@ -0,0 +1,15 @@ +{ + "description": "URI-only duplicate set preserves an existing rich record", + "reducer": "session", + "initial": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///a", { "uri": "file:///b", "repo": "https://example.org/project.git", "origin": { "kind": "repo" } }] + }, + "actions": [{ "type": "session/workingDirectorySet", "directory": "file:///b" }], + "expected": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///a", { "uri": "file:///b", "repo": "https://example.org/project.git", "origin": { "kind": "repo" } }] + } +} diff --git a/types/test-cases/reducers/278-rich-directory-remove-by-uri.json b/types/test-cases/reducers/278-rich-directory-remove-by-uri.json new file mode 100644 index 000000000..d8349cfba --- /dev/null +++ b/types/test-cases/reducers/278-rich-directory-remove-by-uri.json @@ -0,0 +1,15 @@ +{ + "description": "Remove targets a rich entry by URI and preserves remaining order", + "reducer": "session", + "initial": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///a", { "uri": "file:///b", "origin": { "kind": "local" } }, { "uri": "file:///c" }] + }, + "actions": [{ "type": "session/workingDirectoryRemoved", "directory": "file:///b" }], + "expected": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///a", { "uri": "file:///c" }] + } +} diff --git a/types/test-cases/reducers/279-rich-directory-replace-same-uri.json b/types/test-cases/reducers/279-rich-directory-replace-same-uri.json new file mode 100644 index 000000000..bc0f92dea --- /dev/null +++ b/types/test-cases/reducers/279-rich-directory-replace-same-uri.json @@ -0,0 +1,18 @@ +{ + "description": "Same-URI replacement refreshes the full primary record in place", + "reducer": "session", + "initial": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [{ "uri": "file:///a", "origin": { "kind": "local" } }, "file:///b"] + }, + "actions": [{ + "type": "session/workingDirectoryReplaced", "directory": "file:///a", + "replacement": { "uri": "file:///a", "repo": "https://example.org/project.git" } + }], + "expected": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [{ "uri": "file:///a", "repo": "https://example.org/project.git" }, "file:///b"] + } +} diff --git a/types/test-cases/reducers/280-rich-directory-replace-new-uri.json b/types/test-cases/reducers/280-rich-directory-replace-new-uri.json new file mode 100644 index 000000000..183f682c0 --- /dev/null +++ b/types/test-cases/reducers/280-rich-directory-replace-new-uri.json @@ -0,0 +1,24 @@ +{ + "description": "Replacement changes the selected primary URI and provenance atomically", + "reducer": "session", + "initial": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [{ "uri": "file:///a", "origin": { "kind": "local" } }, "file:///b"] + }, + "actions": [{ + "type": "session/workingDirectoryReplaced", "directory": "file:///a", + "replacement": { + "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///a" } + } + }], + "expected": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [{ + "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///a" } + }, "file:///b"] + } +} diff --git a/types/test-cases/reducers/281-rich-directory-replace-deduplicates-later.json b/types/test-cases/reducers/281-rich-directory-replace-deduplicates-later.json new file mode 100644 index 000000000..a2489c6eb --- /dev/null +++ b/types/test-cases/reducers/281-rich-directory-replace-deduplicates-later.json @@ -0,0 +1,18 @@ +{ + "description": "A rich replacement refreshes a later same-URI entry and moves it to the target position", + "reducer": "session", + "initial": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///a", { "uri": "file:///b", "origin": { "kind": "local" } }, { "uri": "file:///c", "repo": "https://example.org/old.git" }] + }, + "actions": [{ + "type": "session/workingDirectoryReplaced", "directory": "file:///b", + "replacement": { "uri": "file:///c", "origin": { "kind": "local" } } + }], + "expected": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///a", { "uri": "file:///c", "origin": { "kind": "local" } }] + } +} diff --git a/types/test-cases/reducers/282-rich-directory-replace-deduplicates-earlier.json b/types/test-cases/reducers/282-rich-directory-replace-deduplicates-earlier.json new file mode 100644 index 000000000..d95642cbf --- /dev/null +++ b/types/test-cases/reducers/282-rich-directory-replace-deduplicates-earlier.json @@ -0,0 +1,18 @@ +{ + "description": "A rich replacement refreshes an earlier same-URI record without moving its primary slot", + "reducer": "session", + "initial": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [{ "uri": "file:///a", "origin": { "kind": "local" } }, "file:///b", "file:///c"] + }, + "actions": [{ + "type": "session/workingDirectoryReplaced", "directory": "file:///c", + "replacement": { "uri": "file:///a", "repo": "https://example.org/project.git", "origin": { "kind": "repo" } } + }], + "expected": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [{ "uri": "file:///a", "repo": "https://example.org/project.git", "origin": { "kind": "repo" } }, "file:///b"] + } +} diff --git a/types/test-cases/reducers/283-uri-directory-replace-preserves-later-record.json b/types/test-cases/reducers/283-uri-directory-replace-preserves-later-record.json new file mode 100644 index 000000000..e811a4db6 --- /dev/null +++ b/types/test-cases/reducers/283-uri-directory-replace-preserves-later-record.json @@ -0,0 +1,15 @@ +{ + "description": "URI-only replacement moves a later rich record without dropping metadata", + "reducer": "session", + "initial": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///a", "file:///b", { "uri": "file:///c", "origin": { "kind": "worktree", "mainWorktree": "file:///main" } }] + }, + "actions": [{ "type": "session/workingDirectoryReplaced", "directory": "file:///b", "replacement": "file:///c" }], + "expected": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///a", { "uri": "file:///c", "origin": { "kind": "worktree", "mainWorktree": "file:///main" } }] + } +} diff --git a/types/test-cases/reducers/284-uri-directory-replace-preserves-earlier-record.json b/types/test-cases/reducers/284-uri-directory-replace-preserves-earlier-record.json new file mode 100644 index 000000000..49a457135 --- /dev/null +++ b/types/test-cases/reducers/284-uri-directory-replace-preserves-earlier-record.json @@ -0,0 +1,15 @@ +{ + "description": "URI-only replacement retains an earlier rich record and removes only the target", + "reducer": "session", + "initial": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [{ "uri": "file:///a", "repo": "https://example.org/project.git" }, "file:///b", { "uri": "file:///c" }] + }, + "actions": [{ "type": "session/workingDirectoryReplaced", "directory": "file:///c", "replacement": "file:///a" }], + "expected": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [{ "uri": "file:///a", "repo": "https://example.org/project.git" }, "file:///b"] + } +} diff --git a/types/test-cases/reducers/285-uri-directory-replace-preserves-same-record.json b/types/test-cases/reducers/285-uri-directory-replace-preserves-same-record.json new file mode 100644 index 000000000..295ed5f86 --- /dev/null +++ b/types/test-cases/reducers/285-uri-directory-replace-preserves-same-record.json @@ -0,0 +1,15 @@ +{ + "description": "Same-URI legacy replacement retains known metadata", + "reducer": "session", + "initial": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [{ "uri": "file:///a", "origin": { "kind": "local" } }, "file:///b"] + }, + "actions": [{ "type": "session/workingDirectoryReplaced", "directory": "file:///a", "replacement": "file:///a" }], + "expected": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [{ "uri": "file:///a", "origin": { "kind": "local" } }, "file:///b"] + } +} diff --git a/types/test-cases/reducers/286-rich-directory-replace-missing-target.json b/types/test-cases/reducers/286-rich-directory-replace-missing-target.json new file mode 100644 index 000000000..5572f3033 --- /dev/null +++ b/types/test-cases/reducers/286-rich-directory-replace-missing-target.json @@ -0,0 +1,18 @@ +{ + "description": "A rich replacement for a missing target leaves the mixed set unchanged", + "reducer": "session", + "initial": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///a", { "uri": "file:///b", "origin": { "kind": "local" } }] + }, + "actions": [{ + "type": "session/workingDirectoryReplaced", "directory": "file:///missing", + "replacement": { "uri": "file:///b", "repo": "https://example.org/project.git" } + }], + "expected": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///a", { "uri": "file:///b", "origin": { "kind": "local" } }] + } +} diff --git a/types/test-cases/round-trips/045-session-config-without-repository.json b/types/test-cases/round-trips/045-session-config-without-repository.json new file mode 100644 index 000000000..1b930eb09 --- /dev/null +++ b/types/test-cases/round-trips/045-session-config-without-repository.json @@ -0,0 +1,50 @@ +{ + "name": "session-config-without-repository", + "group": "A", + "description": "An existing directory-backed session config remains valid without repository properties or values.", + "type": "Snapshot", + "input": { + "resource": "ahp-session:/directory-session", + "state": { + "provider": "example", + "title": "Directory session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": ["file:///work/existing"], + "config": { + "schema": { + "type": "object", + "properties": { + "mode": { "type": "string", "title": "Mode", "sessionMutable": true } + } + }, + "values": { "mode": "review" } + } + }, + "fromSeq": 1 + }, + "acceptableOutputs": [{ + "resource": "ahp-session:/directory-session", + "state": { + "provider": "example", + "title": "Directory session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": ["file:///work/existing"], + "config": { + "schema": { + "type": "object", + "properties": { + "mode": { "type": "string", "title": "Mode", "sessionMutable": true } + } + }, + "values": { "mode": "review" } + } + }, + "fromSeq": 1 + }] +} diff --git a/types/test-cases/round-trips/046-session-directory-provenance.json b/types/test-cases/round-trips/046-session-directory-provenance.json new file mode 100644 index 000000000..d0ffc3741 --- /dev/null +++ b/types/test-cases/round-trips/046-session-directory-provenance.json @@ -0,0 +1,38 @@ +{ + "name": "session-directory-provenance", + "group": "A", + "description": "A ready session preserves multiple actual directories associated with the same source and different preparation results.", + "type": "Snapshot", + "input": { + "resource": "ahp-session:/repository-session", + "state": { + "provider": "example", + "title": "Repository session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": [ + { "uri": "file:///work/project", "repo": "https://example.org/team/project.git", "origin": { "kind": "repo" } }, + { "uri": "file:///work/project-worktree/packages/api", "repo": "https://example.org/team/project.git", "origin": { "kind": "worktree", "mainWorktree": "file:///work/project" } } + ] + }, + "fromSeq": 2 + }, + "acceptableOutputs": [{ + "resource": "ahp-session:/repository-session", + "state": { + "provider": "example", + "title": "Repository session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": [ + { "uri": "file:///work/project", "repo": "https://example.org/team/project.git", "origin": { "kind": "repo" } }, + { "uri": "file:///work/project-worktree/packages/api", "repo": "https://example.org/team/project.git", "origin": { "kind": "worktree", "mainWorktree": "file:///work/project" } } + ] + }, + "fromSeq": 2 + }] +} diff --git a/types/test-cases/round-trips/047-repository-session-creating.json b/types/test-cases/round-trips/047-repository-session-creating.json new file mode 100644 index 000000000..75c8ec967 --- /dev/null +++ b/types/test-cases/round-trips/047-repository-session-creating.json @@ -0,0 +1,30 @@ +{ + "name": "repository-session-creating", + "group": "A", + "description": "A creating session does not claim unresolved directories or echo the original repository request.", + "type": "Snapshot", + "input": { + "resource": "ahp-session:/repository-session", + "state": { + "provider": "example", + "title": "Repository session", + "status": 1, + "lifecycle": "creating", + "activeClients": [], + "chats": [] + }, + "fromSeq": 0 + }, + "acceptableOutputs": [{ + "resource": "ahp-session:/repository-session", + "state": { + "provider": "example", + "title": "Repository session", + "status": 1, + "lifecycle": "creating", + "activeClients": [], + "chats": [] + }, + "fromSeq": 0 + }] +} diff --git a/types/test-cases/round-trips/048-repository-session-failed.json b/types/test-cases/round-trips/048-repository-session-failed.json new file mode 100644 index 000000000..af63994c0 --- /dev/null +++ b/types/test-cases/round-trips/048-repository-session-failed.json @@ -0,0 +1,32 @@ +{ + "name": "repository-session-failed", + "group": "A", + "description": "A failed session carries its creation error without claiming a directory was resolved.", + "type": "Snapshot", + "input": { + "resource": "ahp-session:/repository-session", + "state": { + "provider": "example", + "title": "Repository session", + "status": 1, + "lifecycle": "failed", + "creationError": { "errorType": "preparationFailed", "message": "Preparation failed" }, + "activeClients": [], + "chats": [] + }, + "fromSeq": 1 + }, + "acceptableOutputs": [{ + "resource": "ahp-session:/repository-session", + "state": { + "provider": "example", + "title": "Repository session", + "status": 1, + "lifecycle": "failed", + "creationError": { "errorType": "preparationFailed", "message": "Preparation failed" }, + "activeClients": [], + "chats": [] + }, + "fromSeq": 1 + }] +} diff --git a/types/test-cases/round-trips/049-repository-preparation-capability.json b/types/test-cases/round-trips/049-repository-preparation-capability.json new file mode 100644 index 000000000..235182054 --- /dev/null +++ b/types/test-cases/round-trips/049-repository-preparation-capability.json @@ -0,0 +1,18 @@ +{ + "name": "repository-preparation-capability", + "group": "A", + "description": "An empty host capability supports one default-revision repository without an agent capability.", + "type": "InitializeResult", + "input": { + "protocolVersion": "0.9.0", + "serverSeq": 0, + "snapshots": [], + "repositoryPreparation": {} + }, + "acceptableOutputs": [{ + "protocolVersion": "0.9.0", + "serverSeq": 0, + "snapshots": [], + "repositoryPreparation": {} + }] +} diff --git a/types/test-cases/round-trips/050-repository-preparation-revisions.json b/types/test-cases/round-trips/050-repository-preparation-revisions.json new file mode 100644 index 000000000..a17335aad --- /dev/null +++ b/types/test-cases/round-trips/050-repository-preparation-revisions.json @@ -0,0 +1,18 @@ +{ + "name": "repository-preparation-revisions", + "group": "A", + "description": "Host revision support does not imply multiple-repository support.", + "type": "InitializeResult", + "input": { + "protocolVersion": "0.9.0", + "serverSeq": 0, + "snapshots": [], + "repositoryPreparation": { "revision": true } + }, + "acceptableOutputs": [{ + "protocolVersion": "0.9.0", + "serverSeq": 0, + "snapshots": [], + "repositoryPreparation": { "revision": true } + }] +} diff --git a/types/test-cases/round-trips/051-repository-preparation-single.json b/types/test-cases/round-trips/051-repository-preparation-single.json new file mode 100644 index 000000000..89b785dbf --- /dev/null +++ b/types/test-cases/round-trips/051-repository-preparation-single.json @@ -0,0 +1,18 @@ +{ + "name": "repository-preparation-single", + "group": "A", + "description": "Explicit false capability options round-trip without being confused with absence of host support.", + "type": "InitializeResult", + "input": { + "protocolVersion": "0.9.0", + "serverSeq": 0, + "snapshots": [], + "repositoryPreparation": { "revision": false, "multipleRepositories": false } + }, + "acceptableOutputs": [{ + "protocolVersion": "0.9.0", + "serverSeq": 0, + "snapshots": [], + "repositoryPreparation": { "revision": false, "multipleRepositories": false } + }] +} diff --git a/types/test-cases/round-trips/052-repository-preparation-multiple.json b/types/test-cases/round-trips/052-repository-preparation-multiple.json new file mode 100644 index 000000000..226d1faf8 --- /dev/null +++ b/types/test-cases/round-trips/052-repository-preparation-multiple.json @@ -0,0 +1,18 @@ +{ + "name": "repository-preparation-multiple", + "group": "A", + "description": "Future hosts can advertise multiple repositories and explicit revisions independently of agent capabilities.", + "type": "InitializeResult", + "input": { + "protocolVersion": "0.9.0", + "serverSeq": 0, + "snapshots": [], + "repositoryPreparation": { "revision": true, "multipleRepositories": true } + }, + "acceptableOutputs": [{ + "protocolVersion": "0.9.0", + "serverSeq": 0, + "snapshots": [], + "repositoryPreparation": { "revision": true, "multipleRepositories": true } + }] +} diff --git a/types/test-cases/round-trips/053-repository-session-request-default-revision.json b/types/test-cases/round-trips/053-repository-session-request-default-revision.json new file mode 100644 index 000000000..eef807c9f --- /dev/null +++ b/types/test-cases/round-trips/053-repository-session-request-default-revision.json @@ -0,0 +1,16 @@ +{ + "name": "repository-session-request-default-revision", + "group": "A", + "description": "Creation uses a typed repository list and preserves omission of the requested revision.", + "type": "CreateSessionParams", + "input": { + "channel": "ahp-session:/repository-session", + "repositories": [{ "source": "https://example.org/team/project.git" }], + "config": { "mode": "review" } + }, + "acceptableOutputs": [{ + "channel": "ahp-session:/repository-session", + "repositories": [{ "source": "https://example.org/team/project.git" }], + "config": { "mode": "review" } + }] +} diff --git a/types/test-cases/round-trips/054-repository-session-request-multiple.json b/types/test-cases/round-trips/054-repository-session-request-multiple.json new file mode 100644 index 000000000..269597ded --- /dev/null +++ b/types/test-cases/round-trips/054-repository-session-request-multiple.json @@ -0,0 +1,20 @@ +{ + "name": "repository-session-request-multiple", + "group": "A", + "description": "A future multi-repository request preserves two revisions of the same source in their requested order.", + "type": "CreateSessionParams", + "input": { + "channel": "ahp-session:/repository-session", + "repositories": [ + { "source": "https://example.org/team/project.git", "revision": "feature", "subdirectory": "packages/api" }, + { "source": "https://example.org/team/project.git", "revision": "main" } + ] + }, + "acceptableOutputs": [{ + "channel": "ahp-session:/repository-session", + "repositories": [ + { "source": "https://example.org/team/project.git", "revision": "feature", "subdirectory": "packages/api" }, + { "source": "https://example.org/team/project.git", "revision": "main" } + ] + }] +} diff --git a/types/test-cases/round-trips/055-repository-config-query-context.json b/types/test-cases/round-trips/055-repository-config-query-context.json new file mode 100644 index 000000000..7340226d5 --- /dev/null +++ b/types/test-cases/round-trips/055-repository-config-query-context.json @@ -0,0 +1,18 @@ +{ + "name": "repository-config-query-context", + "group": "A", + "description": "Repository lists and directory context coexist in configuration queries, outside generic config.", + "type": "ResolveSessionConfigParams", + "input": { + "channel": "ahp-root://", + "workingDirectory": "file:///work/context", + "repositories": [{ "source": "https://example.org/team/project.git", "revision": "main", "subdirectory": "packages/api" }], + "config": { "mode": "review" } + }, + "acceptableOutputs": [{ + "channel": "ahp-root://", + "workingDirectory": "file:///work/context", + "repositories": [{ "source": "https://example.org/team/project.git", "revision": "main", "subdirectory": "packages/api" }], + "config": { "mode": "review" } + }] +} diff --git a/types/test-cases/round-trips/056-repository-config-completions-context.json b/types/test-cases/round-trips/056-repository-config-completions-context.json new file mode 100644 index 000000000..4e8708d99 --- /dev/null +++ b/types/test-cases/round-trips/056-repository-config-completions-context.json @@ -0,0 +1,22 @@ +{ + "name": "repository-config-completions-context", + "group": "A", + "description": "Configuration completions preserve the repository list and optional existing directory context.", + "type": "SessionConfigCompletionsParams", + "input": { + "channel": "ahp-root://", + "workingDirectory": "file:///work/context", + "repositories": [{ "source": "https://example.org/team/project.git", "subdirectory": "packages/api" }], + "config": { "mode": "review" }, + "property": "mode", + "query": "re" + }, + "acceptableOutputs": [{ + "channel": "ahp-root://", + "workingDirectory": "file:///work/context", + "repositories": [{ "source": "https://example.org/team/project.git", "subdirectory": "packages/api" }], + "config": { "mode": "review" }, + "property": "mode", + "query": "re" + }] +} diff --git a/types/test-cases/round-trips/057-repository-session-summary.json b/types/test-cases/round-trips/057-repository-session-summary.json new file mode 100644 index 000000000..3deab247d --- /dev/null +++ b/types/test-cases/round-trips/057-repository-session-summary.json @@ -0,0 +1,34 @@ +{ + "name": "repository-session-summary", + "group": "A", + "description": "Session summaries carry URI-only and rich directories with local, absent, and repository provenance.", + "type": "SessionSummary", + "input": { + "resource": "ahp-session:/repository-session", + "provider": "example", + "title": "Repository session", + "status": 1, + "createdAt": "2026-09-20T12:00:00.000Z", + "modifiedAt": "2026-09-20T12:00:00.000Z", + "workingDirectories": [ + "file:///work/legacy", + { "uri": "file:///work/local", "origin": { "kind": "local" } }, + { "uri": "file:///work/unspecified" }, + { "uri": "file:///work/project", "repo": "https://example.org/team/project.git", "origin": { "kind": "repo" } } + ] + }, + "acceptableOutputs": [{ + "resource": "ahp-session:/repository-session", + "provider": "example", + "title": "Repository session", + "status": 1, + "createdAt": "2026-09-20T12:00:00.000Z", + "modifiedAt": "2026-09-20T12:00:00.000Z", + "workingDirectories": [ + "file:///work/legacy", + { "uri": "file:///work/local", "origin": { "kind": "local" } }, + { "uri": "file:///work/unspecified" }, + { "uri": "file:///work/project", "repo": "https://example.org/team/project.git", "origin": { "kind": "repo" } } + ] + }] +} diff --git a/types/test-cases/round-trips/058-repository-session-multiple-ready.json b/types/test-cases/round-trips/058-repository-session-multiple-ready.json new file mode 100644 index 000000000..94ab009dc --- /dev/null +++ b/types/test-cases/round-trips/058-repository-session-multiple-ready.json @@ -0,0 +1,38 @@ +{ + "name": "repository-session-multiple-ready", + "group": "A", + "description": "Ready state can associate different directories with the same source without deduplication.", + "type": "Snapshot", + "input": { + "resource": "ahp-session:/repository-session", + "state": { + "provider": "example", + "title": "Repository session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": [ + { "uri": "file:///work/session-feature", "repo": "https://example.org/team/project.git", "origin": { "kind": "repo" } }, + { "uri": "file:///work/session-main", "repo": "https://example.org/team/project.git", "origin": { "kind": "repo" } } + ] + }, + "fromSeq": 2 + }, + "acceptableOutputs": [{ + "resource": "ahp-session:/repository-session", + "state": { + "provider": "example", + "title": "Repository session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": [ + { "uri": "file:///work/session-feature", "repo": "https://example.org/team/project.git", "origin": { "kind": "repo" } }, + { "uri": "file:///work/session-main", "repo": "https://example.org/team/project.git", "origin": { "kind": "repo" } } + ] + }, + "fromSeq": 2 + }] +} diff --git a/types/test-cases/round-trips/059-initialize-working-directory-info.json b/types/test-cases/round-trips/059-initialize-working-directory-info.json new file mode 100644 index 000000000..a0e5c9a14 --- /dev/null +++ b/types/test-cases/round-trips/059-initialize-working-directory-info.json @@ -0,0 +1,14 @@ +{ + "name": "initialize-working-directory-info", + "group": "A", + "description": "A client opts into rich directories with a presence-only capability.", + "type": "InitializeParams", + "input": { + "channel": "ahp-root://", "clientId": "rich-client", "protocolVersions": ["0.9.0"], + "capabilities": { "workingDirectoryInfo": {} } + }, + "acceptableOutputs": [{ + "channel": "ahp-root://", "clientId": "rich-client", "protocolVersions": ["0.9.0"], + "capabilities": { "workingDirectoryInfo": {} } + }] +} diff --git a/types/test-cases/round-trips/060-initialize-rich-directories.json b/types/test-cases/round-trips/060-initialize-rich-directories.json new file mode 100644 index 000000000..f7cf61cc4 --- /dev/null +++ b/types/test-cases/round-trips/060-initialize-rich-directories.json @@ -0,0 +1,36 @@ +{ + "name": "initialize-rich-directories", + "group": "A", + "description": "Initialize snapshots accept mixed URI-only and rich directory entries.", + "type": "InitializeResult", + "input": { + "protocolVersion": "0.9.0", "serverSeq": 2, + "snapshots": [{ + "resource": "ahp-session:/directories", "fromSeq": 2, + "state": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [ + "file:///legacy", + { "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } } + ] + } + }] + }, + "acceptableOutputs": [{ + "protocolVersion": "0.9.0", "serverSeq": 2, + "snapshots": [{ + "resource": "ahp-session:/directories", "fromSeq": 2, + "state": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": [ + "file:///legacy", + { "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } } + ] + } + }] + }] +} diff --git a/types/test-cases/round-trips/061-subscribe-rich-directories.json b/types/test-cases/round-trips/061-subscribe-rich-directories.json new file mode 100644 index 000000000..1ac5cddbd --- /dev/null +++ b/types/test-cases/round-trips/061-subscribe-rich-directories.json @@ -0,0 +1,32 @@ +{ + "name": "subscribe-rich-directories", + "group": "A", + "description": "Subscribe carries resolved directory metadata in its full session snapshot.", + "type": "SubscribeResult", + "input": { + "snapshot": { + "resource": "ahp-session:/directories", "fromSeq": 2, + "state": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///legacy", { + "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } + }] + } + } + }, + "acceptableOutputs": [{ + "snapshot": { + "resource": "ahp-session:/directories", "fromSeq": 2, + "state": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///legacy", { + "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } + }] + } + } + }] +} diff --git a/types/test-cases/round-trips/062-list-sessions-rich-directories.json b/types/test-cases/round-trips/062-list-sessions-rich-directories.json new file mode 100644 index 000000000..6d1eaedbf --- /dev/null +++ b/types/test-cases/round-trips/062-list-sessions-rich-directories.json @@ -0,0 +1,22 @@ +{ + "name": "list-sessions-rich-directories", + "group": "A", + "description": "Session list summaries preserve mixed directories and their source associations.", + "type": "ListSessionsResult", + "input": { "items": [{ + "resource": "ahp-session:/directories", "provider": "example", "title": "Directories", "status": 1, + "createdAt": "2026-09-21T00:00:00.000Z", "modifiedAt": "2026-09-21T00:00:00.000Z", + "workingDirectories": ["file:///legacy", { + "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } + }] + }] }, + "acceptableOutputs": [{ "items": [{ + "resource": "ahp-session:/directories", "provider": "example", "title": "Directories", "status": 1, + "createdAt": "2026-09-21T00:00:00.000Z", "modifiedAt": "2026-09-21T00:00:00.000Z", + "workingDirectories": ["file:///legacy", { + "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } + }] + }] }] +} diff --git a/types/test-cases/round-trips/063-session-added-rich-directories.json b/types/test-cases/round-trips/063-session-added-rich-directories.json new file mode 100644 index 000000000..58067db23 --- /dev/null +++ b/types/test-cases/round-trips/063-session-added-rich-directories.json @@ -0,0 +1,28 @@ +{ + "name": "session-added-rich-directories", + "group": "A", + "description": "Session-added notifications preserve rich directory metadata.", + "type": "SessionAddedParams", + "input": { + "channel": "ahp-root://", + "summary": { + "resource": "ahp-session:/directories", "provider": "example", "title": "Directories", "status": 1, + "createdAt": "2026-09-21T00:00:00.000Z", "modifiedAt": "2026-09-21T00:00:00.000Z", + "workingDirectories": ["file:///legacy", { + "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } + }] + } + }, + "acceptableOutputs": [{ + "channel": "ahp-root://", + "summary": { + "resource": "ahp-session:/directories", "provider": "example", "title": "Directories", "status": 1, + "createdAt": "2026-09-21T00:00:00.000Z", "modifiedAt": "2026-09-21T00:00:00.000Z", + "workingDirectories": ["file:///legacy", { + "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } + }] + } + }] +} diff --git a/types/test-cases/round-trips/064-session-summary-rich-directories.json b/types/test-cases/round-trips/064-session-summary-rich-directories.json new file mode 100644 index 000000000..44c74ae81 --- /dev/null +++ b/types/test-cases/round-trips/064-session-summary-rich-directories.json @@ -0,0 +1,20 @@ +{ + "name": "session-summary-rich-directories", + "group": "A", + "description": "Summary deltas replace the directory list with complete mixed-format entries.", + "type": "SessionSummaryChangedParams", + "input": { + "channel": "ahp-root://", "session": "ahp-session:/directories", + "changes": { "workingDirectories": ["file:///legacy", { + "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } + }] } + }, + "acceptableOutputs": [{ + "channel": "ahp-root://", "session": "ahp-session:/directories", + "changes": { "workingDirectories": ["file:///legacy", { + "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } + }] } + }] +} diff --git a/types/test-cases/round-trips/065-directory-set-rich.json b/types/test-cases/round-trips/065-directory-set-rich.json new file mode 100644 index 000000000..deeccbbdc --- /dev/null +++ b/types/test-cases/round-trips/065-directory-set-rich.json @@ -0,0 +1,20 @@ +{ + "name": "directory-set-rich", + "group": "A", + "description": "Live set actions carry the full URI-keyed record rather than parallel metadata.", + "type": "ActionEnvelope", + "input": { + "channel": "ahp-session:/directories", "serverSeq": 3, + "action": { "type": "session/workingDirectorySet", "directory": { + "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } + } } + }, + "acceptableOutputs": [{ + "channel": "ahp-session:/directories", "serverSeq": 3, + "action": { "type": "session/workingDirectorySet", "directory": { + "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } + } } + }] +} diff --git a/types/test-cases/round-trips/066-directory-replacement-rich.json b/types/test-cases/round-trips/066-directory-replacement-rich.json new file mode 100644 index 000000000..5cb2f7152 --- /dev/null +++ b/types/test-cases/round-trips/066-directory-replacement-rich.json @@ -0,0 +1,22 @@ +{ + "name": "directory-replacement-rich", + "group": "A", + "description": "Live replacement actions target a URI and carry a complete new directory record.", + "type": "ActionEnvelope", + "input": { + "channel": "ahp-session:/directories", "serverSeq": 4, + "action": { + "type": "session/workingDirectoryReplaced", "directory": "file:///main/packages/api", + "replacement": { "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } } + } + }, + "acceptableOutputs": [{ + "channel": "ahp-session:/directories", "serverSeq": 4, + "action": { + "type": "session/workingDirectoryReplaced", "directory": "file:///main/packages/api", + "replacement": { "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } } + } + }] +} diff --git a/types/test-cases/round-trips/067-directory-unknown-origin.json b/types/test-cases/round-trips/067-directory-unknown-origin.json new file mode 100644 index 000000000..2d08acb56 --- /dev/null +++ b/types/test-cases/round-trips/067-directory-unknown-origin.json @@ -0,0 +1,14 @@ +{ + "name": "directory-unknown-origin", + "group": "A", + "description": "Future working-directory origin kinds survive typed decoders without losing their payload.", + "type": "StateAction", + "input": { + "type": "session/workingDirectorySet", + "directory": { "uri": "file:///future", "origin": { "kind": "future", "location": "host-defined" } } + }, + "acceptableOutputs": [{ + "type": "session/workingDirectorySet", + "directory": { "uri": "file:///future", "origin": { "kind": "future", "location": "host-defined" } } + }] +} diff --git a/types/test-cases/round-trips/068-reconnect-replay-rich-directories.json b/types/test-cases/round-trips/068-reconnect-replay-rich-directories.json new file mode 100644 index 000000000..f8c1703d3 --- /dev/null +++ b/types/test-cases/round-trips/068-reconnect-replay-rich-directories.json @@ -0,0 +1,28 @@ +{ + "name": "reconnect-replay-rich-directories", + "group": "A", + "description": "Reconnect replay carries both rich and URI-only actions under the retained capability.", + "type": "ReconnectResult", + "input": { + "type": "replay", "missing": [], + "actions": [ + { "channel": "ahp-session:/directories", "serverSeq": 5, + "action": { "type": "session/workingDirectorySet", "directory": "file:///legacy" } }, + { "channel": "ahp-session:/directories", "serverSeq": 6, + "action": { "type": "session/workingDirectoryReplaced", "directory": "file:///legacy", + "replacement": { "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } } } } + ] + }, + "acceptableOutputs": [{ + "type": "replay", "missing": [], + "actions": [ + { "channel": "ahp-session:/directories", "serverSeq": 5, + "action": { "type": "session/workingDirectorySet", "directory": "file:///legacy" } }, + { "channel": "ahp-session:/directories", "serverSeq": 6, + "action": { "type": "session/workingDirectoryReplaced", "directory": "file:///legacy", + "replacement": { "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } } } } + ] + }] +} diff --git a/types/test-cases/round-trips/069-reconnect-snapshot-rich-directories.json b/types/test-cases/round-trips/069-reconnect-snapshot-rich-directories.json new file mode 100644 index 000000000..0d159a990 --- /dev/null +++ b/types/test-cases/round-trips/069-reconnect-snapshot-rich-directories.json @@ -0,0 +1,34 @@ +{ + "name": "reconnect-snapshot-rich-directories", + "group": "A", + "description": "Full-snapshot reconnect recovery preserves actual directory provenance.", + "type": "ReconnectResult", + "input": { + "type": "snapshot", + "snapshots": [{ + "resource": "ahp-session:/directories", "fromSeq": 9, + "state": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///legacy", { + "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } + }] + } + }] + }, + "acceptableOutputs": [{ + "type": "snapshot", + "snapshots": [{ + "resource": "ahp-session:/directories", "fromSeq": 9, + "state": { + "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///legacy", { + "uri": "file:///tree/packages/api", "repo": "https://example.org/project.git", + "origin": { "kind": "worktree", "mainWorktree": "file:///main" } + }] + } + }] + }] +} diff --git a/types/test-cases/round-trips/070-initialize-without-directory-info.json b/types/test-cases/round-trips/070-initialize-without-directory-info.json new file mode 100644 index 000000000..6d37c7c38 --- /dev/null +++ b/types/test-cases/round-trips/070-initialize-without-directory-info.json @@ -0,0 +1,8 @@ +{ + "name": "initialize-without-directory-info", + "group": "A", + "description": "Older clients can omit the rich-directory capability.", + "type": "InitializeParams", + "input": { "channel": "ahp-root://", "clientId": "legacy-client", "protocolVersions": ["0.9.0"] }, + "acceptableOutputs": [{ "channel": "ahp-root://", "clientId": "legacy-client", "protocolVersions": ["0.9.0"] }] +} diff --git a/types/test-cases/round-trips/071-initialize-legacy-directories.json b/types/test-cases/round-trips/071-initialize-legacy-directories.json new file mode 100644 index 000000000..18c3b6e58 --- /dev/null +++ b/types/test-cases/round-trips/071-initialize-legacy-directories.json @@ -0,0 +1,24 @@ +{ + "name": "initialize-legacy-directories", + "group": "A", + "description": "The legacy initialize snapshot format remains decodable by new clients.", + "type": "InitializeResult", + "input": { + "protocolVersion": "0.9.0", "serverSeq": 2, + "snapshots": [{ + "resource": "ahp-session:/directories", "fromSeq": 2, + "state": { "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///legacy", "file:///tree/packages/api"] } + }] + }, + "acceptableOutputs": [{ + "protocolVersion": "0.9.0", "serverSeq": 2, + "snapshots": [{ + "resource": "ahp-session:/directories", "fromSeq": 2, + "state": { "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///legacy", "file:///tree/packages/api"] } + }] + }] +} diff --git a/types/test-cases/round-trips/072-subscribe-legacy-directories.json b/types/test-cases/round-trips/072-subscribe-legacy-directories.json new file mode 100644 index 000000000..a2beadf4e --- /dev/null +++ b/types/test-cases/round-trips/072-subscribe-legacy-directories.json @@ -0,0 +1,18 @@ +{ + "name": "subscribe-legacy-directories", + "group": "A", + "description": "Subscribe snapshots from legacy hosts contain only directory URIs.", + "type": "SubscribeResult", + "input": { + "snapshot": { "resource": "ahp-session:/directories", "fromSeq": 2, + "state": { "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///legacy", "file:///tree/packages/api"] } } + }, + "acceptableOutputs": [{ + "snapshot": { "resource": "ahp-session:/directories", "fromSeq": 2, + "state": { "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///legacy", "file:///tree/packages/api"] } } + }] +} diff --git a/types/test-cases/round-trips/073-list-sessions-legacy-directories.json b/types/test-cases/round-trips/073-list-sessions-legacy-directories.json new file mode 100644 index 000000000..06b427ae7 --- /dev/null +++ b/types/test-cases/round-trips/073-list-sessions-legacy-directories.json @@ -0,0 +1,16 @@ +{ + "name": "list-sessions-legacy-directories", + "group": "A", + "description": "Legacy session-list summaries remain URI-only.", + "type": "ListSessionsResult", + "input": { "items": [{ + "resource": "ahp-session:/directories", "provider": "example", "title": "Directories", "status": 1, + "createdAt": "2026-09-21T00:00:00.000Z", "modifiedAt": "2026-09-21T00:00:00.000Z", + "workingDirectories": ["file:///legacy", "file:///tree/packages/api"] + }] }, + "acceptableOutputs": [{ "items": [{ + "resource": "ahp-session:/directories", "provider": "example", "title": "Directories", "status": 1, + "createdAt": "2026-09-21T00:00:00.000Z", "modifiedAt": "2026-09-21T00:00:00.000Z", + "workingDirectories": ["file:///legacy", "file:///tree/packages/api"] + }] }] +} diff --git a/types/test-cases/round-trips/074-session-added-legacy-directories.json b/types/test-cases/round-trips/074-session-added-legacy-directories.json new file mode 100644 index 000000000..0ffa4f6cc --- /dev/null +++ b/types/test-cases/round-trips/074-session-added-legacy-directories.json @@ -0,0 +1,22 @@ +{ + "name": "session-added-legacy-directories", + "group": "A", + "description": "Legacy session-added notifications contain URI-only directories.", + "type": "SessionAddedParams", + "input": { + "channel": "ahp-root://", + "summary": { + "resource": "ahp-session:/directories", "provider": "example", "title": "Directories", "status": 1, + "createdAt": "2026-09-21T00:00:00.000Z", "modifiedAt": "2026-09-21T00:00:00.000Z", + "workingDirectories": ["file:///legacy", "file:///tree/packages/api"] + } + }, + "acceptableOutputs": [{ + "channel": "ahp-root://", + "summary": { + "resource": "ahp-session:/directories", "provider": "example", "title": "Directories", "status": 1, + "createdAt": "2026-09-21T00:00:00.000Z", "modifiedAt": "2026-09-21T00:00:00.000Z", + "workingDirectories": ["file:///legacy", "file:///tree/packages/api"] + } + }] +} diff --git a/types/test-cases/round-trips/075-session-summary-legacy-directories.json b/types/test-cases/round-trips/075-session-summary-legacy-directories.json new file mode 100644 index 000000000..b17c6ae87 --- /dev/null +++ b/types/test-cases/round-trips/075-session-summary-legacy-directories.json @@ -0,0 +1,14 @@ +{ + "name": "session-summary-legacy-directories", + "group": "A", + "description": "Legacy summary deltas replace directories using only URI strings.", + "type": "SessionSummaryChangedParams", + "input": { + "channel": "ahp-root://", "session": "ahp-session:/directories", + "changes": { "workingDirectories": ["file:///legacy", "file:///tree/packages/api"] } + }, + "acceptableOutputs": [{ + "channel": "ahp-root://", "session": "ahp-session:/directories", + "changes": { "workingDirectories": ["file:///legacy", "file:///tree/packages/api"] } + }] +} diff --git a/types/test-cases/round-trips/076-reconnect-replay-legacy-directories.json b/types/test-cases/round-trips/076-reconnect-replay-legacy-directories.json new file mode 100644 index 000000000..188256394 --- /dev/null +++ b/types/test-cases/round-trips/076-reconnect-replay-legacy-directories.json @@ -0,0 +1,24 @@ +{ + "name": "reconnect-replay-legacy-directories", + "group": "A", + "description": "Legacy replay set and replace actions remain URI-only.", + "type": "ReconnectResult", + "input": { + "type": "replay", "missing": [], + "actions": [ + { "channel": "ahp-session:/directories", "serverSeq": 5, + "action": { "type": "session/workingDirectorySet", "directory": "file:///legacy" } }, + { "channel": "ahp-session:/directories", "serverSeq": 6, + "action": { "type": "session/workingDirectoryReplaced", "directory": "file:///legacy", "replacement": "file:///tree/packages/api" } } + ] + }, + "acceptableOutputs": [{ + "type": "replay", "missing": [], + "actions": [ + { "channel": "ahp-session:/directories", "serverSeq": 5, + "action": { "type": "session/workingDirectorySet", "directory": "file:///legacy" } }, + { "channel": "ahp-session:/directories", "serverSeq": 6, + "action": { "type": "session/workingDirectoryReplaced", "directory": "file:///legacy", "replacement": "file:///tree/packages/api" } } + ] + }] +} diff --git a/types/test-cases/round-trips/077-reconnect-snapshot-legacy-directories.json b/types/test-cases/round-trips/077-reconnect-snapshot-legacy-directories.json new file mode 100644 index 000000000..162d355af --- /dev/null +++ b/types/test-cases/round-trips/077-reconnect-snapshot-legacy-directories.json @@ -0,0 +1,24 @@ +{ + "name": "reconnect-snapshot-legacy-directories", + "group": "A", + "description": "Legacy full-snapshot reconnect recovery remains URI-only.", + "type": "ReconnectResult", + "input": { + "type": "snapshot", + "snapshots": [{ + "resource": "ahp-session:/directories", "fromSeq": 9, + "state": { "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///legacy", "file:///tree/packages/api"] } + }] + }, + "acceptableOutputs": [{ + "type": "snapshot", + "snapshots": [{ + "resource": "ahp-session:/directories", "fromSeq": 9, + "state": { "provider": "example", "title": "Directories", "status": 1, + "lifecycle": "ready", "activeClients": [], "chats": [], + "workingDirectories": ["file:///legacy", "file:///tree/packages/api"] } + }] + }] +}