Skip to content

Commit e3efa1e

Browse files
committed
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
1 parent f75d222 commit e3efa1e

30 files changed

Lines changed: 921 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.Combine(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)