Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
config.ContextTier,
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
config.EnableCitations,
config.EnableFileChangeTracking,
wireSystemMessage,
toolFilter.AvailableTools,
toolFilter.ExcludedTools,
Expand Down Expand Up @@ -1360,6 +1361,7 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
config.ContextTier,
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
config.EnableCitations,
config.EnableFileChangeTracking,
wireSystemMessage,
toolFilter.AvailableTools,
toolFilter.ExcludedTools,
Expand Down Expand Up @@ -2719,6 +2721,7 @@ internal record CreateSessionRequest(
ContextTier? ContextTier,
IList<ToolDefinition>? Tools,
bool? EnableCitations,
bool? EnableFileChangeTracking,
SystemMessageConfig? SystemMessage,
IList<string>? AvailableTools,
IList<string>? ExcludedTools,
Expand Down Expand Up @@ -2832,6 +2835,7 @@ internal record ResumeSessionRequest(
ContextTier? ContextTier,
IList<ToolDefinition>? Tools,
bool? EnableCitations,
bool? EnableFileChangeTracking,
SystemMessageConfig? SystemMessage,
IList<string>? AvailableTools,
IList<string>? ExcludedTools,
Expand Down
12 changes: 12 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3136,6 +3136,7 @@ protected SessionConfigBase(SessionConfigBase? other)
DisabledSkills = other.DisabledSkills is not null ? [.. other.DisabledSkills] : null;
DisabledMcpServers = other.DisabledMcpServers is not null ? [.. other.DisabledMcpServers] : null;
EnableCitations = other.EnableCitations;
EnableFileChangeTracking = other.EnableFileChangeTracking;
EnableConfigDiscovery = other.EnableConfigDiscovery;
SkipEmbeddingRetrieval = other.SkipEmbeddingRetrieval;
EmbeddingCacheStorage = other.EmbeddingCacheStorage;
Expand Down Expand Up @@ -3263,6 +3264,17 @@ protected SessionConfigBase(SessionConfigBase? other)
[Experimental(Diagnostics.Experimental)]
public bool? EnableCitations { get; set; }

/// <summary>
/// Opts in to capturing file changes for session rewind and cumulative
/// session diff.
/// </summary>
/// <remarks>
/// On create, capture starts with the first turn. On resume, tracking can be
/// enabled only when the session still has a valid baseline; earlier untracked
/// changes cannot be reconstructed.
/// </remarks>
public bool? EnableFileChangeTracking { get; set; }

/// <summary>
/// Override the default configuration directory location.
/// When specified, the session will use this directory for storing config and state.
Expand Down
81 changes: 81 additions & 0 deletions dotnet/test/E2E/RewindE2ETests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) GitHub, Inc.
// Licensed under the MIT License.

using GitHub.Copilot.Rpc;
using GitHub.Copilot.Test.Harness;

using Xunit;
using Xunit.Abstractions;

namespace GitHub.Copilot.Test.E2E;

public class RewindE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
: E2ETestBase(fixture, "rewind", output)
{
private const string FileName = "rewind-sdk.txt";
private const string FileContent = "SDK rewind content";

[Fact]
public async Task Should_Restore_Tracked_File_And_Conversation()
{
var filePath = Path.Combine(Ctx.WorkDir, FileName);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
await using var session = await CreateSessionAsync(new SessionConfig
{
Model = "claude-sonnet-4.5",
EnableFileChangeTracking = true,
});

var response = await session.SendAndWaitAsync(
new MessageOptions
{
Prompt = $"Use the create tool to create {FileName} containing exactly {FileContent}. "
+ "After the tool succeeds, reply with exactly SDK_REWIND_DONE.",
},
TimeSpan.FromSeconds(30));

Assert.Equal("SDK_REWIND_DONE", response?.Data.Content);
Assert.True(File.Exists(filePath));
Assert.Equal(FileContent, await File.ReadAllTextAsync(filePath));

HistoryListRewindPointsResult? rewindPoints = null;
await TestHelper.WaitForConditionAsync(
async () =>
{
rewindPoints = await session.Rpc.History.ListRewindPointsAsync();
return rewindPoints.UnavailableReason is null;
},
timeout: TimeSpan.FromSeconds(10),
timeoutMessage: "Timed out waiting for rewind points to become available.",
pollInterval: TimeSpan.FromMilliseconds(100));

Assert.NotNull(rewindPoints);
Assert.True(rewindPoints.FileChangeTrackingEnabled);
var rewindPoint = Assert.Single(rewindPoints.Points);
Assert.True(rewindPoint.CanRestoreFiles);
Assert.Equal(1, rewindPoint.FileCount);

var preview = await session.Rpc.History.PreviewRewindAsync(rewindPoint.EventId);
Assert.True(preview.Available);
var previewFile = Assert.Single(preview.Files);
Assert.Equal(
Path.GetFullPath(filePath),
Path.GetFullPath(previewFile.Path),
OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);

var rewind = await session.Rpc.History.RewindAsync(
rewindPoint.EventId,
HistoryRewindMode.ConversationAndFiles);

Assert.Equal(HistoryRewindOutcome.Success, rewind.Outcome);
Assert.True(rewind.EventsRemoved > 0);
var restoredFile = Assert.Single(rewind.RestoredFiles);
Assert.Equal(
Path.GetFullPath(filePath),
Path.GetFullPath(restoredFile),
OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
Assert.False(File.Exists(filePath));

var events = await session.GetEventsAsync();
Assert.DoesNotContain(events, sessionEvent => sessionEvent.Id.ToString() == rewindPoint.EventId);
}
}
2 changes: 2 additions & 0 deletions dotnet/test/Unit/CloneTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public void SessionConfig_Clone_CopiesAllProperties()
AdditionalDirectories = ["/shared", "/generated"],
Streaming = true,
EnableCitations = true,
EnableFileChangeTracking = true,
EnableSessionTelemetry = false,
EnableExperimentalMode = true,
EnableOnDemandInstructionDiscovery = true,
Expand Down Expand Up @@ -125,6 +126,7 @@ public void SessionConfig_Clone_CopiesAllProperties()
Assert.Equal(original.AdditionalDirectories, clone.AdditionalDirectories);
Assert.Equal(original.Streaming, clone.Streaming);
Assert.Equal(original.EnableCitations, clone.EnableCitations);
Assert.Equal(original.EnableFileChangeTracking, clone.EnableFileChangeTracking);
Assert.Equal(original.EnableSessionTelemetry, clone.EnableSessionTelemetry);
Assert.Equal(original.EnableExperimentalMode, clone.EnableExperimentalMode);
Assert.Equal(original.EnableOnDemandInstructionDiscovery, clone.EnableOnDemandInstructionDiscovery);
Expand Down
4 changes: 4 additions & 0 deletions dotnet/test/Unit/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -483,13 +483,15 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO
createRequestType,
("SessionId", "session-id"),
("EnableCitations", true),
("EnableFileChangeTracking", true),
("ExcludedBuiltInAgents", excludedAgents),
("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 12.5 }));

var createJson = JsonSerializer.Serialize(createRequest, createRequestType, options);
using var createDocument = JsonDocument.Parse(createJson);
var createRoot = createDocument.RootElement;
Assert.True(createRoot.GetProperty("enableCitations").GetBoolean());
Assert.True(createRoot.GetProperty("enableFileChangeTracking").GetBoolean());
Assert.Equal("explore", createRoot.GetProperty("excludedBuiltinAgents")[0].GetString());
Assert.Equal(12.5, createRoot.GetProperty("sessionLimits").GetProperty("maxAiCredits").GetDouble());

Expand All @@ -498,13 +500,15 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO
resumeRequestType,
("SessionId", "session-id"),
("EnableCitations", true),
("EnableFileChangeTracking", true),
("ExcludedBuiltInAgents", excludedAgents),
("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 7.25 }));

var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options);
using var resumeDocument = JsonDocument.Parse(resumeJson);
var resumeRoot = resumeDocument.RootElement;
Assert.True(resumeRoot.GetProperty("enableCitations").GetBoolean());
Assert.True(resumeRoot.GetProperty("enableFileChangeTracking").GetBoolean());
Assert.Equal("task", resumeRoot.GetProperty("excludedBuiltinAgents")[1].GetString());
Assert.Equal(7.25, resumeRoot.GetProperty("sessionLimits").GetProperty("maxAiCredits").GetDouble());
}
Expand Down
2 changes: 2 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
req.Models = config.Models
req.EnableSessionTelemetry = config.EnableSessionTelemetry
req.EnableCitations = config.EnableCitations
req.EnableFileChangeTracking = config.EnableFileChangeTracking
req.SessionLimits = config.SessionLimits
req.IsExperimentalMode = config.EnableExperimentalMode
req.SkipCustomInstructions = config.SkipCustomInstructions
Expand Down Expand Up @@ -1148,6 +1149,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
req.ToolFilterPrecedence = precedence
req.ExcludedBuiltInAgents = config.ExcludedBuiltInAgents
req.EnableCitations = config.EnableCitations
req.EnableFileChangeTracking = config.EnableFileChangeTracking
req.SessionLimits = config.SessionLimits
if config.Streaming != nil {
req.Streaming = config.Streaming
Expand Down
22 changes: 14 additions & 8 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,14 +398,15 @@ func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) {
})

_, err := client.CreateSession(t.Context(), &SessionConfig{
ExcludedBuiltInAgents: []string{"explore"},
EnableCitations: Bool(true),
SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ptr(30)},
ExcludedBuiltInAgents: []string{"explore"},
EnableCitations: Bool(true),
EnableFileChangeTracking: Bool(true),
SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ptr(30)},
})
if err != nil {
t.Fatalf("CreateSession failed: %v", err)
}
assertNewSessionOptions(t, <-createParams, true, "explore", 30)
assertNewSessionOptions(t, <-createParams, true, true, "explore", 30)

resumeParams := make(chan json.RawMessage, 1)
server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
Expand All @@ -414,14 +415,15 @@ func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) {
})

_, err = client.ResumeSessionWithOptions(t.Context(), "resumed-options", &ResumeSessionConfig{
ExcludedBuiltInAgents: []string{"task"},
EnableCitations: Bool(false),
SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ptr(15)},
ExcludedBuiltInAgents: []string{"task"},
EnableCitations: Bool(false),
EnableFileChangeTracking: Bool(false),
SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ptr(15)},
})
if err != nil {
t.Fatalf("ResumeSessionWithOptions failed: %v", err)
}
assertNewSessionOptions(t, <-resumeParams, false, "task", 15)
assertNewSessionOptions(t, <-resumeParams, false, false, "task", 15)
}

func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) {
Expand All @@ -445,6 +447,7 @@ func assertNewSessionOptions(
t *testing.T,
params json.RawMessage,
expectedCitations bool,
expectedFileChangeTracking bool,
expectedAgent string,
expectedCredits float64,
) {
Expand All @@ -457,6 +460,9 @@ func assertNewSessionOptions(
if decoded["enableCitations"] != expectedCitations {
t.Fatalf("expected enableCitations=%v, got %v", expectedCitations, decoded["enableCitations"])
}
if decoded["enableFileChangeTracking"] != expectedFileChangeTracking {
t.Fatalf("expected enableFileChangeTracking=%v, got %v", expectedFileChangeTracking, decoded["enableFileChangeTracking"])
}
agents, ok := decoded["excludedBuiltinAgents"].([]any)
if !ok || len(agents) != 1 || agents[0] != expectedAgent {
t.Fatalf("expected excludedBuiltinAgents=[%q], got %#v", expectedAgent, decoded["excludedBuiltinAgents"])
Expand Down
Loading
Loading