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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions dotnet/src/Generated/Rpc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,28 @@ public class AccountGetQuotaResult
public Dictionary<string, AccountGetQuotaResultQuotaSnapshotsValue> QuotaSnapshots { get; set; } = [];
}

public class SessionLogResult
{
/// <summary>The unique identifier of the emitted session event</summary>
[JsonPropertyName("eventId")]
public Guid EventId { get; set; }
}

internal class SessionLogRequest
{
[JsonPropertyName("sessionId")]
public string SessionId { get; set; } = string.Empty;

[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;

[JsonPropertyName("level")]
public SessionLogRequestLevel? Level { get; set; }

[JsonPropertyName("ephemeral")]
public bool? Ephemeral { get; set; }
}

public class SessionModelGetCurrentResult
{
[JsonPropertyName("modelId")]
Expand All @@ -217,6 +239,9 @@ internal class SessionModelSwitchToRequest

[JsonPropertyName("modelId")]
public string ModelId { get; set; } = string.Empty;

[JsonPropertyName("reasoningEffort")]
public SessionModelSwitchToRequestReasoningEffort? ReasoningEffort { get; set; }
}

public class SessionModeGetResult
Expand Down Expand Up @@ -511,6 +536,32 @@ internal class SessionPermissionsHandlePendingPermissionRequestRequest
public object Result { get; set; } = null!;
}

[JsonConverter(typeof(JsonStringEnumConverter<SessionLogRequestLevel>))]
public enum SessionLogRequestLevel
{
[JsonStringEnumMemberName("info")]
Info,
[JsonStringEnumMemberName("warning")]
Warning,
[JsonStringEnumMemberName("error")]
Error,
}


[JsonConverter(typeof(JsonStringEnumConverter<SessionModelSwitchToRequestReasoningEffort>))]
public enum SessionModelSwitchToRequestReasoningEffort
{
[JsonStringEnumMemberName("low")]
Low,
[JsonStringEnumMemberName("medium")]
Medium,
[JsonStringEnumMemberName("high")]
High,
[JsonStringEnumMemberName("xhigh")]
Xhigh,
}


[JsonConverter(typeof(JsonStringEnumConverter<SessionModeGetResultMode>))]
public enum SessionModeGetResultMode
{
Expand Down Expand Up @@ -643,6 +694,13 @@ internal SessionRpc(JsonRpc rpc, string sessionId)
public ToolsApi Tools { get; }

public PermissionsApi Permissions { get; }

/// <summary>Calls "session.log".</summary>
public async Task<SessionLogResult> LogAsync(string message, SessionLogRequestLevel? level = null, bool? ephemeral = null, CancellationToken cancellationToken = default)
{
var request = new SessionLogRequest { SessionId = _sessionId, Message = message, Level = level, Ephemeral = ephemeral };
return await CopilotClient.InvokeRpcAsync<SessionLogResult>(_rpc, "session.log", [request], cancellationToken);
}
}

public class ModelApi
Expand All @@ -664,9 +722,9 @@ public async Task<SessionModelGetCurrentResult> GetCurrentAsync(CancellationToke
}

/// <summary>Calls "session.model.switchTo".</summary>
public async Task<SessionModelSwitchToResult> SwitchToAsync(string modelId, CancellationToken cancellationToken = default)
public async Task<SessionModelSwitchToResult> SwitchToAsync(string modelId, SessionModelSwitchToRequestReasoningEffort? reasoningEffort = null, CancellationToken cancellationToken = default)
{
var request = new SessionModelSwitchToRequest { SessionId = _sessionId, ModelId = modelId };
var request = new SessionModelSwitchToRequest { SessionId = _sessionId, ModelId = modelId, ReasoningEffort = reasoningEffort };
return await CopilotClient.InvokeRpcAsync<SessionModelSwitchToResult>(_rpc, "session.model.switchTo", [request], cancellationToken);
}
}
Expand Down Expand Up @@ -909,6 +967,8 @@ public async Task<SessionPermissionsHandlePendingPermissionRequestResult> Handle
[JsonSerializable(typeof(SessionCompactionCompactResult))]
[JsonSerializable(typeof(SessionFleetStartRequest))]
[JsonSerializable(typeof(SessionFleetStartResult))]
[JsonSerializable(typeof(SessionLogRequest))]
[JsonSerializable(typeof(SessionLogResult))]
[JsonSerializable(typeof(SessionModeGetRequest))]
[JsonSerializable(typeof(SessionModeGetResult))]
[JsonSerializable(typeof(SessionModeSetRequest))]
Expand Down
111 changes: 111 additions & 0 deletions dotnet/src/Generated/SessionEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ namespace GitHub.Copilot.SDK;
[JsonDerivedType(typeof(SubagentSelectedEvent), "subagent.selected")]
[JsonDerivedType(typeof(SubagentStartedEvent), "subagent.started")]
[JsonDerivedType(typeof(SystemMessageEvent), "system.message")]
[JsonDerivedType(typeof(SystemNotificationEvent), "system.notification")]
[JsonDerivedType(typeof(ToolExecutionCompleteEvent), "tool.execution_complete")]
[JsonDerivedType(typeof(ToolExecutionPartialResultEvent), "tool.execution_partial_result")]
[JsonDerivedType(typeof(ToolExecutionProgressEvent), "tool.execution_progress")]
Expand Down Expand Up @@ -657,6 +658,18 @@ public partial class SystemMessageEvent : SessionEvent
public required SystemMessageData Data { get; set; }
}

/// <summary>
/// Event: system.notification
/// </summary>
public partial class SystemNotificationEvent : SessionEvent
{
[JsonIgnore]
public override string Type => "system.notification";

[JsonPropertyName("data")]
public required SystemNotificationData Data { get; set; }
}

/// <summary>
/// Event: permission.requested
/// </summary>
Expand Down Expand Up @@ -825,6 +838,10 @@ public partial class SessionStartData
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("context")]
public SessionStartDataContext? Context { get; set; }

[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("alreadyInUse")]
public bool? AlreadyInUse { get; set; }
}

public partial class SessionResumeData
Expand All @@ -838,6 +855,10 @@ public partial class SessionResumeData
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("context")]
public SessionResumeDataContext? Context { get; set; }

[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("alreadyInUse")]
public bool? AlreadyInUse { get; set; }
}

public partial class SessionErrorData
Expand Down Expand Up @@ -1522,6 +1543,15 @@ public partial class SystemMessageData
public SystemMessageDataMetadata? Metadata { get; set; }
}

public partial class SystemNotificationData
{
[JsonPropertyName("content")]
public required string Content { get; set; }

[JsonPropertyName("kind")]
public required SystemNotificationDataKind Kind { get; set; }
}

public partial class PermissionRequestedData
{
[JsonPropertyName("requestId")]
Expand Down Expand Up @@ -2095,6 +2125,72 @@ public partial class SystemMessageDataMetadata
public Dictionary<string, object>? Variables { get; set; }
}

public partial class SystemNotificationDataKindAgentCompleted : SystemNotificationDataKind
{
[JsonIgnore]
public override string Type => "agent_completed";

[JsonPropertyName("agentId")]
public required string AgentId { get; set; }

[JsonPropertyName("agentType")]
public required string AgentType { get; set; }

[JsonPropertyName("status")]
public required SystemNotificationDataKindAgentCompletedStatus Status { get; set; }

[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("description")]
public string? Description { get; set; }

[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("prompt")]
public string? Prompt { get; set; }
}

public partial class SystemNotificationDataKindShellCompleted : SystemNotificationDataKind
{
[JsonIgnore]
public override string Type => "shell_completed";

[JsonPropertyName("shellId")]
public required string ShellId { get; set; }

[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("exitCode")]
public double? ExitCode { get; set; }

[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("description")]
public string? Description { get; set; }
}

public partial class SystemNotificationDataKindShellDetachedCompleted : SystemNotificationDataKind
{
[JsonIgnore]
public override string Type => "shell_detached_completed";

[JsonPropertyName("shellId")]
public required string ShellId { get; set; }

[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("description")]
public string? Description { get; set; }
}

[JsonPolymorphic(
TypeDiscriminatorPropertyName = "type",
UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)]
[JsonDerivedType(typeof(SystemNotificationDataKindAgentCompleted), "agent_completed")]
[JsonDerivedType(typeof(SystemNotificationDataKindShellCompleted), "shell_completed")]
[JsonDerivedType(typeof(SystemNotificationDataKindShellDetachedCompleted), "shell_detached_completed")]
public partial class SystemNotificationDataKind
{
[JsonPropertyName("type")]
public virtual string Type { get; set; } = string.Empty;
}


public partial class PermissionRequestShellCommandsItem
{
[JsonPropertyName("identifier")]
Expand Down Expand Up @@ -2390,6 +2486,15 @@ public enum SystemMessageDataRole
Developer,
}

[JsonConverter(typeof(JsonStringEnumConverter<SystemNotificationDataKindAgentCompletedStatus>))]
public enum SystemNotificationDataKindAgentCompletedStatus
{
[JsonStringEnumMemberName("completed")]
Completed,
[JsonStringEnumMemberName("failed")]
Failed,
}

[JsonConverter(typeof(JsonStringEnumConverter<PermissionCompletedDataResultKind>))]
public enum PermissionCompletedDataResultKind
{
Expand Down Expand Up @@ -2536,6 +2641,12 @@ public enum PermissionCompletedDataResultKind
[JsonSerializable(typeof(SystemMessageData))]
[JsonSerializable(typeof(SystemMessageDataMetadata))]
[JsonSerializable(typeof(SystemMessageEvent))]
[JsonSerializable(typeof(SystemNotificationData))]
[JsonSerializable(typeof(SystemNotificationDataKind))]
[JsonSerializable(typeof(SystemNotificationDataKindAgentCompleted))]
[JsonSerializable(typeof(SystemNotificationDataKindShellCompleted))]
[JsonSerializable(typeof(SystemNotificationDataKindShellDetachedCompleted))]
[JsonSerializable(typeof(SystemNotificationEvent))]
[JsonSerializable(typeof(ToolExecutionCompleteData))]
[JsonSerializable(typeof(ToolExecutionCompleteDataError))]
[JsonSerializable(typeof(ToolExecutionCompleteDataResult))]
Expand Down
24 changes: 23 additions & 1 deletion dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,29 @@ await InvokeRpcAsync<object>(
/// </example>
public async Task SetModelAsync(string model, CancellationToken cancellationToken = default)
{
await Rpc.Model.SwitchToAsync(model, cancellationToken);
await Rpc.Model.SwitchToAsync(model, cancellationToken: cancellationToken);
}

/// <summary>
/// Log a message to the session timeline.
/// The message appears in the session event stream and is visible to SDK consumers
/// and (for non-ephemeral messages) persisted to the session event log on disk.
/// </summary>
/// <param name="message">The message to log.</param>
/// <param name="level">Log level (default: info).</param>
/// <param name="ephemeral">When <c>true</c>, the message is not persisted to disk.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
/// <example>
/// <code>
/// await session.LogAsync("Build completed successfully");
/// await session.LogAsync("Disk space low", level: SessionLogRequestLevel.Warning);
/// await session.LogAsync("Connection failed", level: SessionLogRequestLevel.Error);
/// await session.LogAsync("Temporary status", ephemeral: true);
/// </code>
/// </example>
public async Task LogAsync(string message, SessionLogRequestLevel? level = null, bool? ephemeral = null, CancellationToken cancellationToken = default)
{
await Rpc.LogAsync(message, level, ephemeral, cancellationToken);
}

/// <summary>
Expand Down
48 changes: 48 additions & 0 deletions dotnet/test/SessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*--------------------------------------------------------------------------------------------*/

using GitHub.Copilot.SDK.Test.Harness;
using GitHub.Copilot.SDK.Rpc;
using Microsoft.Extensions.AI;
using System.ComponentModel;
using Xunit;
Expand Down Expand Up @@ -404,4 +405,51 @@ public async Task Should_Set_Model_On_Existing_Session()
var modelChanged = await modelChangedTask;
Assert.Equal("gpt-4.1", modelChanged.Data.NewModel);
}

[Fact]
public async Task Should_Log_Messages_At_Various_Levels()
{
var session = await CreateSessionAsync();
var events = new List<SessionEvent>();
session.On(evt => events.Add(evt));

await session.LogAsync("Info message");
await session.LogAsync("Warning message", level: SessionLogRequestLevel.Warning);
await session.LogAsync("Error message", level: SessionLogRequestLevel.Error);
await session.LogAsync("Ephemeral message", ephemeral: true);

// Poll until all 4 notification events arrive
await WaitForAsync(() =>
{
var notifications = events.Where(e =>
e is SessionInfoEvent info && info.Data.InfoType == "notification" ||
e is SessionWarningEvent warn && warn.Data.WarningType == "notification" ||
e is SessionErrorEvent err && err.Data.ErrorType == "notification"
).ToList();
return notifications.Count >= 4;
}, timeout: TimeSpan.FromSeconds(10));
Comment on lines +413 to +430
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

session.On(evt => events.Add(evt)) appends to a List<SessionEvent> from the JSON-RPC event dispatch path while WaitForAsync concurrently enumerates events via LINQ. This can race and throw InvalidOperationException (collection modified) or produce flaky counts. Consider using a thread-safe collection (e.g., ConcurrentQueue<SessionEvent> / ConcurrentBag<SessionEvent>) or guarding both writes and reads with a lock (snapshot to array before filtering).

Copilot uses AI. Check for mistakes.

var infoEvent = events.OfType<SessionInfoEvent>().First(e => e.Data.Message == "Info message");
Assert.Equal("notification", infoEvent.Data.InfoType);

var warningEvent = events.OfType<SessionWarningEvent>().First(e => e.Data.Message == "Warning message");
Assert.Equal("notification", warningEvent.Data.WarningType);

var errorEvent = events.OfType<SessionErrorEvent>().First(e => e.Data.Message == "Error message");
Assert.Equal("notification", errorEvent.Data.ErrorType);

var ephemeralEvent = events.OfType<SessionInfoEvent>().First(e => e.Data.Message == "Ephemeral message");
Assert.Equal("notification", ephemeralEvent.Data.InfoType);
}

private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition())
{
if (DateTime.UtcNow > deadline)
throw new TimeoutException($"Condition not met within {timeout}");
await Task.Delay(100);
}
}
}
Loading
Loading