Skip to content
Open
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
2 changes: 1 addition & 1 deletion dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1302,7 +1302,7 @@ await Rpc.SessionFs.SetProviderAsync(
_options.SessionFs.InitialCwd,
_options.SessionFs.SessionStatePath,
_options.SessionFs.Conventions,
cancellationToken);
cancellationToken: cancellationToken);
}

private void ConfigureSessionFsHandlers(CopilotSession session, Func<CopilotSession, SessionFsProvider>? createSessionFsHandler)
Expand Down
770 changes: 269 additions & 501 deletions dotnet/src/Generated/Rpc.cs

Large diffs are not rendered by default.

550 changes: 357 additions & 193 deletions dotnet/src/Generated/SessionEvents.cs

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions dotnet/src/SessionFsProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,24 @@ public abstract class SessionFsProvider : ISessionFsHandler
/// <param name="cancellationToken">Cancellation token.</param>
protected abstract Task RenameAsync(string src, string dest, CancellationToken cancellationToken);

/// <summary>Executes a SQLite query against the per-session database.</summary>
/// <param name="sessionId">Target session identifier.</param>
/// <param name="query">SQL query to execute.</param>
/// <param name="queryType">How to execute the query.</param>
/// <param name="parameters">Optional named bind parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
protected abstract Task<SessionFsSqliteQueryResult> SqliteQueryAsync(
string sessionId,
string query,
SessionFsSqliteQueryType queryType,
IDictionary<string, object>? parameters,
CancellationToken cancellationToken);

/// <summary>Checks whether the per-session SQLite database already exists.</summary>
/// <param name="sessionId">Target session identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
protected abstract Task<bool> SqliteExistsAsync(string sessionId, CancellationToken cancellationToken);

// ---- ISessionFsHandler implementation (private, handles error mapping) ----

async Task<SessionFsReadFileResult> ISessionFsHandler.ReadFileAsync(SessionFsReadFileRequest request, CancellationToken cancellationToken)
Expand Down Expand Up @@ -226,6 +244,45 @@ async Task<SessionFsReaddirWithTypesResult> ISessionFsHandler.ReaddirWithTypesAs
}
}

async Task<SessionFsSqliteQueryResult> ISessionFsHandler.SqliteQueryAsync(SessionFsSqliteQueryRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);

try
{
return await SqliteQueryAsync(request.SessionId, request.Query, request.QueryType, request.Params, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (!IsCriticalException(ex))
{
return new SessionFsSqliteQueryResult { Error = ToSessionFsError(ex) };
}
}

async Task<SessionFsSqliteExistsResult> ISessionFsHandler.SqliteExistsAsync(SessionFsSqliteExistsRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);

try
{
var exists = await SqliteExistsAsync(request.SessionId, cancellationToken).ConfigureAwait(false);
return new SessionFsSqliteExistsResult { Exists = exists };
}
catch (Exception ex) when (!IsCriticalException(ex))
{
return new SessionFsSqliteExistsResult { Exists = false };
}
}

private static bool IsCriticalException(Exception ex) =>
ex is OperationCanceledException
or OutOfMemoryException
or StackOverflowException
or AccessViolationException
or AppDomainUnloadedException
or BadImageFormatException
or CannotUnloadAppDomainException
or InvalidProgramException;

private static SessionFsError ToSessionFsError(Exception ex)
{
var code = ex is FileNotFoundException or DirectoryNotFoundException
Expand Down
19 changes: 0 additions & 19 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -798,25 +798,6 @@ public class AutoModeSwitchRequest
public double? RetryAfterSeconds { get; set; }
}

/// <summary>
/// Response to an auto-mode-switch request.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<AutoModeSwitchResponse>))]
public enum AutoModeSwitchResponse
{
/// <summary>Approve the switch for this rate-limit cycle.</summary>
[JsonStringEnumMemberName("yes")]
Yes,

/// <summary>Approve and remember the choice for this session.</summary>
[JsonStringEnumMemberName("yes_always")]
YesAlways,

/// <summary>Decline the switch.</summary>
[JsonStringEnumMemberName("no")]
No
}

/// <summary>
/// Context for an auto-mode-switch request invocation.
/// </summary>
Expand Down
18 changes: 12 additions & 6 deletions dotnet/test/E2E/ModeHandlersE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public async Task Should_Invoke_Exit_Plan_Mode_Handler_When_Model_Uses_Tool()
timeoutDescription: "exit_plan_mode.requested event");
var completedEventTask = TestHelper.GetNextEventOfTypeAsync<ExitPlanModeCompletedEvent>(
session,
evt => evt.Data.Approved == true && evt.Data.SelectedAction == "interactive",
evt => evt.Data.Approved == true && evt.Data.SelectedAction.GetValueOrDefault() == ExitPlanModeAction.Interactive,
TimeSpan.FromSeconds(30),
timeoutDescription: "exit_plan_mode.completed event");

Expand All @@ -66,12 +66,18 @@ public async Task Should_Invoke_Exit_Plan_Mode_Handler_When_Model_Uses_Tool()

var requestedEvent = await requestedEventTask;
Assert.Equal(request.Summary, requestedEvent.Data.Summary);
Assert.Equal(request.Actions, requestedEvent.Data.Actions);
Assert.Equal(request.RecommendedAction, requestedEvent.Data.RecommendedAction);
Assert.Equal(request.Actions, requestedEvent.Data.Actions.Select(action => action.Value));
Assert.Equal(request.RecommendedAction, requestedEvent.Data.RecommendedAction.Value);

var completedEvent = await completedEventTask;
Assert.True(completedEvent.Data.Approved);
Assert.Equal("interactive", completedEvent.Data.SelectedAction);
if (completedEvent.Data.SelectedAction is not { } selectedAction)
{
Assert.Fail("Expected a selected action.");
return;
}

Assert.Equal("interactive", selectedAction.Value);
Assert.Equal("Approved by the C# E2E test", completedEvent.Data.Feedback);

Assert.NotNull(response);
Expand Down Expand Up @@ -104,7 +110,7 @@ public async Task Should_Invoke_Auto_Mode_Switch_Handler_When_Rate_Limited()
timeoutDescription: "auto_mode_switch.requested event");
var completedEventTask = GetNextEventOfTypeAllowingRateLimitAsync<AutoModeSwitchCompletedEvent>(
session,
evt => evt.Data.Response == "yes",
evt => evt.Data.Response == AutoModeSwitchResponse.Yes,
TimeSpan.FromSeconds(30),
timeoutDescription: "auto_mode_switch.completed event");
var modelChangeTask = GetNextEventOfTypeAllowingRateLimitAsync<SessionModelChangeEvent>(
Expand Down Expand Up @@ -134,7 +140,7 @@ public async Task Should_Invoke_Auto_Mode_Switch_Handler_When_Rate_Limited()
Assert.Equal(request.RetryAfterSeconds, requestedEvent.Data.RetryAfterSeconds);

var completedEvent = await completedEventTask;
Assert.Equal("yes", completedEvent.Data.Response);
Assert.Equal(AutoModeSwitchResponse.Yes, completedEvent.Data.Response);

var modelChange = await modelChangeTask;
Assert.Equal("rate_limit_auto_switch", modelChange.Data.Cause);
Expand Down
6 changes: 3 additions & 3 deletions dotnet/test/E2E/RpcEventSideEffectsE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@ public async Task Should_Emit_Mode_Changed_Event_When_Mode_Set()
// Subscribe before invoking RPC; events may arrive after the RPC completes.
var modeChangedTask = TestHelper.GetNextEventOfTypeAsync<SessionModeChangedEvent>(
session,
evt => evt.Data.NewMode == "plan" && evt.Data.PreviousMode == "interactive",
evt => evt.Data.NewMode == SessionMode.Plan && evt.Data.PreviousMode == SessionMode.Interactive,
EventTimeout,
timeoutDescription: "session.mode_changed event for interactive→plan");

await session.Rpc.Mode.SetAsync(SessionMode.Plan);

var evt = await modeChangedTask;
Assert.Equal("plan", evt.Data.NewMode);
Assert.Equal("interactive", evt.Data.PreviousMode);
Assert.Equal(SessionMode.Plan, evt.Data.NewMode);
Assert.Equal(SessionMode.Interactive, evt.Data.PreviousMode);
}

[Fact]
Expand Down
2 changes: 1 addition & 1 deletion dotnet/test/E2E/RpcServerE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ await ConfigureAuthenticatedUserAsync(
Assert.Equal(2, chatQuota.Overage);
Assert.True(chatQuota.UsageAllowedWithExhaustedQuota);
Assert.True(chatQuota.OverageAllowedWithExhaustedQuota);
Assert.Equal("2026-04-30T00:00:00Z", chatQuota.ResetDate);
Assert.Equal(DateTimeOffset.Parse("2026-04-30T00:00:00Z"), chatQuota.ResetDate);
}

[Fact]
Expand Down
8 changes: 4 additions & 4 deletions dotnet/test/E2E/RpcTasksAndHandlersE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ await TestHelper.WaitForConditionAsync(
Assert.Equal("general-purpose", task.AgentType);
Assert.Equal("Reply with TASK_AGENT_DONE exactly.", task.Prompt);
Assert.Equal("SDK background agent coverage", task.Description);
Assert.Equal(TaskAgentInfoExecutionMode.Background, task.ExecutionMode);
Assert.Equal(GitHub.Copilot.SDK.Rpc.TaskExecutionMode.Background, task.ExecutionMode);
Assert.False(task.CanPromoteToBackground.GetValueOrDefault());
Assert.NotEqual(default, task.StartedAt);

Expand All @@ -114,16 +114,16 @@ await TestHelper.WaitForConditionAsync(
task = await FindAgentTaskAsync(session, started.AgentId);
return task?.LatestResponse?.Contains("TASK_AGENT_DONE", StringComparison.Ordinal) == true
|| task?.Result?.Contains("TASK_AGENT_DONE", StringComparison.Ordinal) == true
|| task?.Status == TaskAgentInfoStatus.Completed
|| task?.Status == TaskAgentInfoStatus.Failed;
|| task?.Status == GitHub.Copilot.SDK.Rpc.TaskStatus.Completed
|| task?.Status == GitHub.Copilot.SDK.Rpc.TaskStatus.Failed;
},
timeout: TimeSpan.FromSeconds(60),
timeoutMessage: $"Background agent task '{started.AgentId}' did not produce a final observable state.");

Assert.NotNull(task);
Assert.Contains("TASK_AGENT_DONE", task.LatestResponse ?? task.Result ?? string.Empty);

if (task.Status == TaskAgentInfoStatus.Idle)
if (task.Status == GitHub.Copilot.SDK.Rpc.TaskStatus.Idle)
{
var cancel = await session.Rpc.Tasks.CancelAsync(started.AgentId);
Assert.True(cancel.Cancelled);
Expand Down
27 changes: 27 additions & 0 deletions dotnet/test/E2E/SessionFsE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ public async Task SessionFsProvider_Converts_Exceptions_To_Rpc_Errors()
AssertFsError((await handler.ReaddirWithTypesAsync(new SessionFsReaddirWithTypesRequest { Path = "missing-dir" })).Error);
AssertFsError(await handler.RmAsync(new SessionFsRmRequest { Path = "missing.txt" }));
AssertFsError(await handler.RenameAsync(new SessionFsRenameRequest { Src = "missing.txt", Dest = "dest.txt" }));
AssertFsError((await handler.SqliteQueryAsync(new SessionFsSqliteQueryRequest { Query = "select 1" })).Error);

var sqliteExists = await handler.SqliteExistsAsync(new SessionFsSqliteExistsRequest());
Assert.False(sqliteExists.Exists);

var unknown = (ISessionFsHandler)new ThrowingSessionFsProvider(new InvalidOperationException("bad path"));
var unknownError = await unknown.WriteFileAsync(new SessionFsWriteFileRequest { Path = "bad.txt", Content = "content" });
Expand Down Expand Up @@ -603,6 +607,17 @@ protected override Task RmAsync(string path, bool recursive, bool force, Cancell

protected override Task RenameAsync(string src, string dest, CancellationToken cancellationToken) =>
Task.FromException(exception);

protected override Task<SessionFsSqliteQueryResult> SqliteQueryAsync(
string sessionId,
string query,
SessionFsSqliteQueryType queryType,
IDictionary<string, object>? parameters,
CancellationToken cancellationToken) =>
Task.FromException<SessionFsSqliteQueryResult>(exception);

protected override Task<bool> SqliteExistsAsync(string sessionId, CancellationToken cancellationToken) =>
Task.FromException<bool>(exception);
}

private sealed class TestSessionFsHandler(string sessionId, string rootDir) : SessionFsProvider
Expand Down Expand Up @@ -736,6 +751,18 @@ protected override Task RenameAsync(string src, string dest, CancellationToken c
return Task.CompletedTask;
}

protected override Task<SessionFsSqliteQueryResult> SqliteQueryAsync(
string sessionId,
string query,
SessionFsSqliteQueryType queryType,
IDictionary<string, object>? parameters,
CancellationToken cancellationToken) =>
Task.FromException<SessionFsSqliteQueryResult>(
new NotSupportedException("SQLite session filesystem operations are not supported by this provider."));

protected override Task<bool> SqliteExistsAsync(string sessionId, CancellationToken cancellationToken) =>
Task.FromResult(false);

private string ResolvePath(string sessionPath)
{
var normalizedSessionId = NormalizeRelativePathSegment(sessionId, nameof(sessionId));
Expand Down
2 changes: 1 addition & 1 deletion dotnet/test/E2E/SkillsE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ public async Task Should_Control_Ambient_Project_Skills_With_EnableConfigDiscove
var enabledSkills = await enabledSession.Rpc.Skills.ListAsync();
var discoveredSkill = Assert.Single(enabledSkills.Skills, skill => string.Equals(skill.Name, skillName, StringComparison.Ordinal));
Assert.True(discoveredSkill.Enabled);
Assert.Equal("project", discoveredSkill.Source);
Assert.Equal(SkillSource.Project, discoveredSkill.Source);
Assert.EndsWith(Path.Join(skillName, "SKILL.md"), discoveredSkill.Path);
await enabledSession.DisposeAsync();
}
Expand Down
14 changes: 10 additions & 4 deletions dotnet/test/Harness/E2ETestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ namespace GitHub.Copilot.SDK.Test.Harness;

public sealed class E2ETestContext : IAsyncDisposable
{
private const string DefaultGitHubToken = "fake-token-for-e2e-tests";

public string HomeDir { get; }
public string WorkDir { get; }
public string ProxyUrl { get; }
Expand Down Expand Up @@ -49,6 +51,11 @@ public static async Task<E2ETestContext> CreateAsync()

var proxy = new CapiProxy();
var proxyUrl = await proxy.StartAsync();
await proxy.SetCopilotUserByTokenAsync(DefaultGitHubToken, new CopilotUserConfig(
Login: "e2e-test-user",
CopilotPlan: "individual_pro",
Endpoints: new CopilotUserEndpoints(Api: proxyUrl, Telemetry: "https://localhost:1/telemetry"),
AnalyticsTrackingId: "e2e-test-tracking-id"));

return new E2ETestContext(homeDir, workDir, proxyUrl, proxy, repoRoot);
}
Expand Down Expand Up @@ -190,8 +197,8 @@ public IReadOnlyDictionary<string, string> GetEnvironment()
}
if (Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true")
{
env["GH_TOKEN"] = "fake-token-for-e2e-tests";
env["GITHUB_TOKEN"] = "fake-token-for-e2e-tests";
env["GH_TOKEN"] = DefaultGitHubToken;
env["GITHUB_TOKEN"] = DefaultGitHubToken;
}

return env!;
Expand All @@ -216,11 +223,10 @@ public CopilotClient CreateClient(
}

if (autoInjectGitHubToken
&& !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"))
&& string.IsNullOrEmpty(options.GitHubToken)
&& string.IsNullOrEmpty(options.CliUrl))
{
options.GitHubToken = "fake-token-for-e2e-tests";
options.GitHubToken = DefaultGitHubToken;
}

var client = new CopilotClient(options);
Expand Down
6 changes: 3 additions & 3 deletions dotnet/test/Unit/ForwardCompatibilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ public void RpcEnum_WithNonStringValue_ThrowsJsonException()
[Fact]
public void RpcEnum_DefaultValue_HasEmptyStringValue()
{
GitHub.Copilot.SDK.Rpc.SessionMode mode = default;
GitHub.Copilot.SDK.SessionMode mode = default;

Assert.Equal(string.Empty, mode.Value);
Assert.Equal(string.Empty, mode.ToString());
Expand All @@ -249,7 +249,7 @@ public void RpcEnum_DefaultValue_HasEmptyStringValue()
[Fact]
public void RpcEnum_DefaultValueSerialization_ThrowsJsonException()
{
GitHub.Copilot.SDK.Rpc.SessionMode mode = default;
GitHub.Copilot.SDK.SessionMode mode = default;

var exception = Assert.Throws<JsonException>(() => JsonSerializer.Serialize(
mode,
Expand Down Expand Up @@ -304,5 +304,5 @@ public void FromJson_UnknownEventType_PreservesAgentIdNull()
}
}

[JsonSerializable(typeof(GitHub.Copilot.SDK.Rpc.SessionMode))]
[JsonSerializable(typeof(GitHub.Copilot.SDK.SessionMode))]
internal partial class ForwardCompatibilityJsonContext : JsonSerializerContext;
2 changes: 1 addition & 1 deletion dotnet/test/Unit/SessionEventSerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public class SessionEventSerializationTests
{
ShutdownType = ShutdownType.Routine,
TotalPremiumRequests = 1,
TotalApiDurationMs = 100,
TotalApiDurationMs = TimeSpan.FromMilliseconds(100),
SessionStartTime = 1773609948932,
CodeChanges = new ShutdownCodeChanges
{
Expand Down
24 changes: 23 additions & 1 deletion go/internal/e2e/per_session_auth_e2e_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package e2e

import (
"strings"
"testing"

copilot "github.com/github/copilot-sdk/go"
Expand Down Expand Up @@ -99,7 +100,15 @@ func TestPerSessionAuthE2E(t *testing.T) {
t.Run("should be unauthenticated without token", func(t *testing.T) {
ctx.ConfigureForTest(t)

session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
noTokenClient := copilot.NewClient(&copilot.ClientOptions{
CLIPath: ctx.CLIPath,
Cwd: ctx.WorkDir,
Env: withoutAuthEnv(append(ctx.Env(), "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL)),
UseLoggedInUser: copilot.Bool(false),
})
t.Cleanup(func() { noTokenClient.ForceStop() })

session, err := noTokenClient.CreateSession(t.Context(), &copilot.SessionConfig{
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
})
if err != nil {
Expand Down Expand Up @@ -132,3 +141,16 @@ func TestPerSessionAuthE2E(t *testing.T) {
t.Logf("Got expected error: %v", err)
})
}

func withoutAuthEnv(env []string) []string {
filtered := make([]string, 0, len(env)+3)
for _, entry := range env {
if strings.HasPrefix(entry, "COPILOT_SDK_AUTH_TOKEN=") ||
strings.HasPrefix(entry, "GH_TOKEN=") ||
strings.HasPrefix(entry, "GITHUB_TOKEN=") {
continue
}
filtered = append(filtered, entry)
}
return append(filtered, "COPILOT_SDK_AUTH_TOKEN=", "GH_TOKEN=", "GITHUB_TOKEN=")
}
Loading
Loading