Skip to content

Commit a6d3ed1

Browse files
authored
Add rewind support across all SDKs (#2321)
* Add rewind support across SDKs Expose file change tracking on create and resume session options in all six SDKs, and add shared replay coverage that verifies conversation-and-file rewind behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 91a7e2ab-88a5-4365-accf-cbea1e1391e9 * Fix rewind E2E formatting Apply the Java, Python, and Rust formatters required by CI. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 91a7e2ab-88a5-4365-accf-cbea1e1391e9 * Normalize rewind paths on Windows Treat both Windows path separators as equivalent when validating replayed rewind paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 91a7e2ab-88a5-4365-accf-cbea1e1391e9 * Harden rewind test path construction Use Path.Join so a rooted file name cannot discard the E2E working directory. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 91a7e2ab-88a5-4365-accf-cbea1e1391e9 --------- Copilot-Session: 91a7e2ab-88a5-4365-accf-cbea1e1391e9
1 parent f75d222 commit a6d3ed1

30 files changed

Lines changed: 920 additions & 12 deletions

dotnet/src/Client.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,6 +1141,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
11411141
config.ContextTier,
11421142
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
11431143
config.EnableCitations,
1144+
config.EnableFileChangeTracking,
11441145
wireSystemMessage,
11451146
toolFilter.AvailableTools,
11461147
toolFilter.ExcludedTools,
@@ -1360,6 +1361,7 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
13601361
config.ContextTier,
13611362
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
13621363
config.EnableCitations,
1364+
config.EnableFileChangeTracking,
13631365
wireSystemMessage,
13641366
toolFilter.AvailableTools,
13651367
toolFilter.ExcludedTools,
@@ -2719,6 +2721,7 @@ internal record CreateSessionRequest(
27192721
ContextTier? ContextTier,
27202722
IList<ToolDefinition>? Tools,
27212723
bool? EnableCitations,
2724+
bool? EnableFileChangeTracking,
27222725
SystemMessageConfig? SystemMessage,
27232726
IList<string>? AvailableTools,
27242727
IList<string>? ExcludedTools,
@@ -2832,6 +2835,7 @@ internal record ResumeSessionRequest(
28322835
ContextTier? ContextTier,
28332836
IList<ToolDefinition>? Tools,
28342837
bool? EnableCitations,
2838+
bool? EnableFileChangeTracking,
28352839
SystemMessageConfig? SystemMessage,
28362840
IList<string>? AvailableTools,
28372841
IList<string>? ExcludedTools,

dotnet/src/Types.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3136,6 +3136,7 @@ protected SessionConfigBase(SessionConfigBase? other)
31363136
DisabledSkills = other.DisabledSkills is not null ? [.. other.DisabledSkills] : null;
31373137
DisabledMcpServers = other.DisabledMcpServers is not null ? [.. other.DisabledMcpServers] : null;
31383138
EnableCitations = other.EnableCitations;
3139+
EnableFileChangeTracking = other.EnableFileChangeTracking;
31393140
EnableConfigDiscovery = other.EnableConfigDiscovery;
31403141
SkipEmbeddingRetrieval = other.SkipEmbeddingRetrieval;
31413142
EmbeddingCacheStorage = other.EmbeddingCacheStorage;
@@ -3263,6 +3264,17 @@ protected SessionConfigBase(SessionConfigBase? other)
32633264
[Experimental(Diagnostics.Experimental)]
32643265
public bool? EnableCitations { get; set; }
32653266

3267+
/// <summary>
3268+
/// Opts in to capturing file changes for session rewind and cumulative
3269+
/// session diff.
3270+
/// </summary>
3271+
/// <remarks>
3272+
/// On create, capture starts with the first turn. On resume, tracking can be
3273+
/// enabled only when the session still has a valid baseline; earlier untracked
3274+
/// changes cannot be reconstructed.
3275+
/// </remarks>
3276+
public bool? EnableFileChangeTracking { get; set; }
3277+
32663278
/// <summary>
32673279
/// Override the default configuration directory location.
32683280
/// When specified, the session will use this directory for storing config and state.

dotnet/test/E2E/RewindE2ETests.cs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) GitHub, Inc.
2+
// Licensed under the MIT License.
3+
4+
using GitHub.Copilot.Rpc;
5+
using GitHub.Copilot.Test.Harness;
6+
7+
using Xunit;
8+
using Xunit.Abstractions;
9+
10+
namespace GitHub.Copilot.Test.E2E;
11+
12+
public class RewindE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
13+
: E2ETestBase(fixture, "rewind", output)
14+
{
15+
private const string FileName = "rewind-sdk.txt";
16+
private const string FileContent = "SDK rewind content";
17+
18+
[Fact]
19+
public async Task Should_Restore_Tracked_File_And_Conversation()
20+
{
21+
var filePath = Path.Join(Ctx.WorkDir, FileName);
22+
await using var session = await CreateSessionAsync(new SessionConfig
23+
{
24+
Model = "claude-sonnet-4.5",
25+
EnableFileChangeTracking = true,
26+
});
27+
28+
var response = await session.SendAndWaitAsync(
29+
new MessageOptions
30+
{
31+
Prompt = $"Use the create tool to create {FileName} containing exactly {FileContent}. "
32+
+ "After the tool succeeds, reply with exactly SDK_REWIND_DONE.",
33+
},
34+
TimeSpan.FromSeconds(30));
35+
36+
Assert.Equal("SDK_REWIND_DONE", response?.Data.Content);
37+
Assert.True(File.Exists(filePath));
38+
Assert.Equal(FileContent, await File.ReadAllTextAsync(filePath));
39+
40+
HistoryListRewindPointsResult? rewindPoints = null;
41+
await TestHelper.WaitForConditionAsync(
42+
async () =>
43+
{
44+
rewindPoints = await session.Rpc.History.ListRewindPointsAsync();
45+
return rewindPoints.UnavailableReason is null;
46+
},
47+
timeout: TimeSpan.FromSeconds(10),
48+
timeoutMessage: "Timed out waiting for rewind points to become available.",
49+
pollInterval: TimeSpan.FromMilliseconds(100));
50+
51+
Assert.NotNull(rewindPoints);
52+
Assert.True(rewindPoints.FileChangeTrackingEnabled);
53+
var rewindPoint = Assert.Single(rewindPoints.Points);
54+
Assert.True(rewindPoint.CanRestoreFiles);
55+
Assert.Equal(1, rewindPoint.FileCount);
56+
57+
var preview = await session.Rpc.History.PreviewRewindAsync(rewindPoint.EventId);
58+
Assert.True(preview.Available);
59+
var previewFile = Assert.Single(preview.Files);
60+
Assert.Equal(
61+
Path.GetFullPath(filePath),
62+
Path.GetFullPath(previewFile.Path),
63+
OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
64+
65+
var rewind = await session.Rpc.History.RewindAsync(
66+
rewindPoint.EventId,
67+
HistoryRewindMode.ConversationAndFiles);
68+
69+
Assert.Equal(HistoryRewindOutcome.Success, rewind.Outcome);
70+
Assert.True(rewind.EventsRemoved > 0);
71+
var restoredFile = Assert.Single(rewind.RestoredFiles);
72+
Assert.Equal(
73+
Path.GetFullPath(filePath),
74+
Path.GetFullPath(restoredFile),
75+
OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
76+
Assert.False(File.Exists(filePath));
77+
78+
var events = await session.GetEventsAsync();
79+
Assert.DoesNotContain(events, sessionEvent => sessionEvent.Id.ToString() == rewindPoint.EventId);
80+
}
81+
}

dotnet/test/Unit/CloneTests.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ public void SessionConfig_Clone_CopiesAllProperties()
7878
AdditionalDirectories = ["/shared", "/generated"],
7979
Streaming = true,
8080
EnableCitations = true,
81+
EnableFileChangeTracking = true,
8182
EnableSessionTelemetry = false,
8283
EnableExperimentalMode = true,
8384
EnableOnDemandInstructionDiscovery = true,
@@ -125,6 +126,7 @@ public void SessionConfig_Clone_CopiesAllProperties()
125126
Assert.Equal(original.AdditionalDirectories, clone.AdditionalDirectories);
126127
Assert.Equal(original.Streaming, clone.Streaming);
127128
Assert.Equal(original.EnableCitations, clone.EnableCitations);
129+
Assert.Equal(original.EnableFileChangeTracking, clone.EnableFileChangeTracking);
128130
Assert.Equal(original.EnableSessionTelemetry, clone.EnableSessionTelemetry);
129131
Assert.Equal(original.EnableExperimentalMode, clone.EnableExperimentalMode);
130132
Assert.Equal(original.EnableOnDemandInstructionDiscovery, clone.EnableOnDemandInstructionDiscovery);

dotnet/test/Unit/SerializationTests.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,13 +483,15 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO
483483
createRequestType,
484484
("SessionId", "session-id"),
485485
("EnableCitations", true),
486+
("EnableFileChangeTracking", true),
486487
("ExcludedBuiltInAgents", excludedAgents),
487488
("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 12.5 }));
488489

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

@@ -498,13 +500,15 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO
498500
resumeRequestType,
499501
("SessionId", "session-id"),
500502
("EnableCitations", true),
503+
("EnableFileChangeTracking", true),
501504
("ExcludedBuiltInAgents", excludedAgents),
502505
("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 7.25 }));
503506

504507
var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options);
505508
using var resumeDocument = JsonDocument.Parse(resumeJson);
506509
var resumeRoot = resumeDocument.RootElement;
507510
Assert.True(resumeRoot.GetProperty("enableCitations").GetBoolean());
511+
Assert.True(resumeRoot.GetProperty("enableFileChangeTracking").GetBoolean());
508512
Assert.Equal("task", resumeRoot.GetProperty("excludedBuiltinAgents")[1].GetString());
509513
Assert.Equal(7.25, resumeRoot.GetProperty("sessionLimits").GetProperty("maxAiCredits").GetDouble());
510514
}

go/client.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
799799
req.Models = config.Models
800800
req.EnableSessionTelemetry = config.EnableSessionTelemetry
801801
req.EnableCitations = config.EnableCitations
802+
req.EnableFileChangeTracking = config.EnableFileChangeTracking
802803
req.SessionLimits = config.SessionLimits
803804
req.IsExperimentalMode = config.EnableExperimentalMode
804805
req.SkipCustomInstructions = config.SkipCustomInstructions
@@ -1148,6 +1149,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
11481149
req.ToolFilterPrecedence = precedence
11491150
req.ExcludedBuiltInAgents = config.ExcludedBuiltInAgents
11501151
req.EnableCitations = config.EnableCitations
1152+
req.EnableFileChangeTracking = config.EnableFileChangeTracking
11511153
req.SessionLimits = config.SessionLimits
11521154
if config.Streaming != nil {
11531155
req.Streaming = config.Streaming

go/client_test.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -398,14 +398,15 @@ func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) {
398398
})
399399

400400
_, err := client.CreateSession(t.Context(), &SessionConfig{
401-
ExcludedBuiltInAgents: []string{"explore"},
402-
EnableCitations: Bool(true),
403-
SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ptr(30)},
401+
ExcludedBuiltInAgents: []string{"explore"},
402+
EnableCitations: Bool(true),
403+
EnableFileChangeTracking: Bool(true),
404+
SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ptr(30)},
404405
})
405406
if err != nil {
406407
t.Fatalf("CreateSession failed: %v", err)
407408
}
408-
assertNewSessionOptions(t, <-createParams, true, "explore", 30)
409+
assertNewSessionOptions(t, <-createParams, true, true, "explore", 30)
409410

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

416417
_, err = client.ResumeSessionWithOptions(t.Context(), "resumed-options", &ResumeSessionConfig{
417-
ExcludedBuiltInAgents: []string{"task"},
418-
EnableCitations: Bool(false),
419-
SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ptr(15)},
418+
ExcludedBuiltInAgents: []string{"task"},
419+
EnableCitations: Bool(false),
420+
EnableFileChangeTracking: Bool(false),
421+
SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ptr(15)},
420422
})
421423
if err != nil {
422424
t.Fatalf("ResumeSessionWithOptions failed: %v", err)
423425
}
424-
assertNewSessionOptions(t, <-resumeParams, false, "task", 15)
426+
assertNewSessionOptions(t, <-resumeParams, false, false, "task", 15)
425427
}
426428

427429
func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) {
@@ -445,6 +447,7 @@ func assertNewSessionOptions(
445447
t *testing.T,
446448
params json.RawMessage,
447449
expectedCitations bool,
450+
expectedFileChangeTracking bool,
448451
expectedAgent string,
449452
expectedCredits float64,
450453
) {
@@ -457,6 +460,9 @@ func assertNewSessionOptions(
457460
if decoded["enableCitations"] != expectedCitations {
458461
t.Fatalf("expected enableCitations=%v, got %v", expectedCitations, decoded["enableCitations"])
459462
}
463+
if decoded["enableFileChangeTracking"] != expectedFileChangeTracking {
464+
t.Fatalf("expected enableFileChangeTracking=%v, got %v", expectedFileChangeTracking, decoded["enableFileChangeTracking"])
465+
}
460466
agents, ok := decoded["excludedBuiltinAgents"].([]any)
461467
if !ok || len(agents) != 1 || agents[0] != expectedAgent {
462468
t.Fatalf("expected excludedBuiltinAgents=[%q], got %#v", expectedAgent, decoded["excludedBuiltinAgents"])

0 commit comments

Comments
 (0)