diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 2df1f05d1..8116be28e 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1141,6 +1141,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.ContextTier, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), config.EnableCitations, + config.EnableFileChangeTracking, wireSystemMessage, toolFilter.AvailableTools, toolFilter.ExcludedTools, @@ -1360,6 +1361,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.ContextTier, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), config.EnableCitations, + config.EnableFileChangeTracking, wireSystemMessage, toolFilter.AvailableTools, toolFilter.ExcludedTools, @@ -2719,6 +2721,7 @@ internal record CreateSessionRequest( ContextTier? ContextTier, IList? Tools, bool? EnableCitations, + bool? EnableFileChangeTracking, SystemMessageConfig? SystemMessage, IList? AvailableTools, IList? ExcludedTools, @@ -2832,6 +2835,7 @@ internal record ResumeSessionRequest( ContextTier? ContextTier, IList? Tools, bool? EnableCitations, + bool? EnableFileChangeTracking, SystemMessageConfig? SystemMessage, IList? AvailableTools, IList? ExcludedTools, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 680955a01..59f892140 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -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; @@ -3263,6 +3264,17 @@ protected SessionConfigBase(SessionConfigBase? other) [Experimental(Diagnostics.Experimental)] public bool? EnableCitations { get; set; } + /// + /// Opts in to capturing file changes for session rewind and cumulative + /// session diff. + /// + /// + /// 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. + /// + public bool? EnableFileChangeTracking { get; set; } + /// /// Override the default configuration directory location. /// When specified, the session will use this directory for storing config and state. diff --git a/dotnet/test/E2E/RewindE2ETests.cs b/dotnet/test/E2E/RewindE2ETests.cs new file mode 100644 index 000000000..ced06b93f --- /dev/null +++ b/dotnet/test/E2E/RewindE2ETests.cs @@ -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.Join(Ctx.WorkDir, FileName); + 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); + } +} diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index 88c653283..844eca135 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -78,6 +78,7 @@ public void SessionConfig_Clone_CopiesAllProperties() AdditionalDirectories = ["/shared", "/generated"], Streaming = true, EnableCitations = true, + EnableFileChangeTracking = true, EnableSessionTelemetry = false, EnableExperimentalMode = true, EnableOnDemandInstructionDiscovery = true, @@ -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); diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 9bccf3d77..6edf16809 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -483,6 +483,7 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO createRequestType, ("SessionId", "session-id"), ("EnableCitations", true), + ("EnableFileChangeTracking", true), ("ExcludedBuiltInAgents", excludedAgents), ("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 12.5 })); @@ -490,6 +491,7 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO 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()); @@ -498,6 +500,7 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO resumeRequestType, ("SessionId", "session-id"), ("EnableCitations", true), + ("EnableFileChangeTracking", true), ("ExcludedBuiltInAgents", excludedAgents), ("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 7.25 })); @@ -505,6 +508,7 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO 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()); } diff --git a/go/client.go b/go/client.go index 856e933ea..7d9b9e6bf 100644 --- a/go/client.go +++ b/go/client.go @@ -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 @@ -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 diff --git a/go/client_test.go b/go/client_test.go index 3322d7741..8152fb469 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -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) { @@ -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) { @@ -445,6 +447,7 @@ func assertNewSessionOptions( t *testing.T, params json.RawMessage, expectedCitations bool, + expectedFileChangeTracking bool, expectedAgent string, expectedCredits float64, ) { @@ -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"]) diff --git a/go/internal/e2e/rewind_e2e_test.go b/go/internal/e2e/rewind_e2e_test.go new file mode 100644 index 000000000..b15e546eb --- /dev/null +++ b/go/internal/e2e/rewind_e2e_test.go @@ -0,0 +1,152 @@ +package e2e + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +const ( + rewindFileName = "rewind-sdk.txt" + rewindFileContent = "SDK rewind content" +) + +func TestRewindE2E(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("should restore tracked file and conversation", func(t *testing.T) { + ctx.ConfigureForTest(t) + filePath := filepath.Join(ctx.WorkDir, rewindFileName) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + Model: "claude-sonnet-4.5", + EnableFileChangeTracking: copilot.Bool(true), + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer session.Disconnect() + + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the create tool to create " + rewindFileName + " containing exactly " + + rewindFileContent + ". After the tool succeeds, reply with exactly SDK_REWIND_DONE.", + }) + if err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + responseData, ok := response.Data.(*copilot.AssistantMessageData) + if !ok || responseData.Content != "SDK_REWIND_DONE" { + t.Fatalf("Expected SDK_REWIND_DONE response, got %+v", response) + } + content, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Failed to read created file: %v", err) + } + if string(content) != rewindFileContent { + t.Fatalf("Expected file content %q, got %q", rewindFileContent, content) + } + + rewindPoints := waitForRewindPoints(t, session) + if !rewindPoints.FileChangeTrackingEnabled { + t.Fatal("Expected file change tracking to be enabled") + } + if len(rewindPoints.Points) != 1 { + t.Fatalf("Expected one rewind point, got %+v", rewindPoints.Points) + } + rewindPoint := rewindPoints.Points[0] + if !rewindPoint.CanRestoreFiles || rewindPoint.FileCount != 1 { + t.Fatalf("Expected one restorable file, got %+v", rewindPoint) + } + + preview, err := session.RPC.History.PreviewRewind(t.Context(), &rpc.HistoryPreviewRewindRequest{ + EventID: rewindPoint.EventID, + }) + if err != nil { + t.Fatalf("PreviewRewind failed: %v", err) + } + if !preview.Available || len(preview.Files) != 1 { + t.Fatalf("Expected one available preview file, got %+v", preview) + } + assertSameRewindPath(t, filePath, preview.Files[0].Path) + + rewind, err := session.RPC.History.Rewind(t.Context(), &rpc.HistoryRewindRequest{ + EventID: rewindPoint.EventID, + Mode: rpc.HistoryRewindModeConversationAndFiles, + }) + if err != nil { + t.Fatalf("Rewind failed: %v", err) + } + if rewind.Outcome != rpc.HistoryRewindOutcomeSuccess { + t.Fatalf("Expected successful rewind, got %+v", rewind) + } + if rewind.EventsRemoved == nil || *rewind.EventsRemoved < 1 { + t.Fatalf("Expected rewind to remove events, got %+v", rewind) + } + if len(rewind.RestoredFiles) != 1 { + t.Fatalf("Expected one restored file, got %+v", rewind.RestoredFiles) + } + assertSameRewindPath(t, filePath, rewind.RestoredFiles[0]) + if _, err := os.Stat(filePath); !os.IsNotExist(err) { + t.Fatalf("Expected rewound file to be removed, stat error: %v", err) + } + + events, err := session.GetEvents(t.Context()) + if err != nil { + t.Fatalf("GetEvents failed: %v", err) + } + for _, event := range events { + if event.ID == rewindPoint.EventID { + t.Fatalf("Expected rewound event %q to be removed", rewindPoint.EventID) + } + } + }) +} + +func waitForRewindPoints(t *testing.T, session *copilot.Session) *rpc.HistoryListRewindPointsResult { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + result, err := session.RPC.History.ListRewindPoints(t.Context()) + if err != nil { + t.Fatalf("ListRewindPoints failed: %v", err) + } + if result.UnavailableReason == nil { + return result + } + if time.Now().After(deadline) { + t.Fatalf("Timed out waiting for rewind points: %s", *result.UnavailableReason) + } + time.Sleep(100 * time.Millisecond) + } +} + +func assertSameRewindPath(t *testing.T, expected, actual string) { + t.Helper() + expectedPath, err := filepath.Abs(expected) + if err != nil { + t.Fatalf("Failed to resolve expected path: %v", err) + } + actualPath, err := filepath.Abs(actual) + if err != nil { + t.Fatalf("Failed to resolve actual path: %v", err) + } + + expectedPath = filepath.Clean(expectedPath) + actualPath = filepath.Clean(actualPath) + if runtime.GOOS == "windows" { + if !strings.EqualFold(expectedPath, actualPath) { + t.Fatalf("Expected path %q, got %q", expectedPath, actualPath) + } + } else if expectedPath != actualPath { + t.Fatalf("Expected path %q, got %q", expectedPath, actualPath) + } +} diff --git a/go/types.go b/go/types.go index 6d6a877d3..8690695f2 100644 --- a/go/types.go +++ b/go/types.go @@ -1326,6 +1326,9 @@ type SessionConfig struct { // Experimental: EnableCitations is part of an experimental model capability // surface and may change or be removed in future SDK or CLI releases. EnableCitations *bool + // EnableFileChangeTracking opts in to capturing file changes from the first + // turn for session rewind and cumulative session diff. + EnableFileChangeTracking *bool // SessionLimits applies limits to this session's current accounting window. // // Experimental: SessionLimits is part of an experimental runtime accounting @@ -1790,6 +1793,10 @@ type ResumeSessionConfig struct { // Experimental: EnableCitations is part of an experimental model capability // surface and may change or be removed in future SDK or CLI releases. EnableCitations *bool + // EnableFileChangeTracking opts in to capturing file changes for session + // rewind and cumulative session diff when the resumed session has a valid + // baseline. Earlier untracked changes cannot be reconstructed. + EnableFileChangeTracking *bool // SessionLimits applies limits to this session's current accounting window. // // Experimental: SessionLimits is part of an experimental runtime accounting @@ -2416,6 +2423,7 @@ type createSessionRequest struct { Models []ProviderModelConfig `json:"models,omitempty"` EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` EnableCitations *bool `json:"enableCitations,omitempty"` + EnableFileChangeTracking *bool `json:"enableFileChangeTracking,omitempty"` SessionLimits *rpc.SessionLimitsConfig `json:"sessionLimits,omitempty"` IsExperimentalMode *bool `json:"isExperimentalMode,omitempty"` SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` @@ -2511,6 +2519,7 @@ type resumeSessionRequest struct { Models []ProviderModelConfig `json:"models,omitempty"` EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` EnableCitations *bool `json:"enableCitations,omitempty"` + EnableFileChangeTracking *bool `json:"enableFileChangeTracking,omitempty"` SessionLimits *rpc.SessionLimitsConfig `json:"sessionLimits,omitempty"` IsExperimentalMode *bool `json:"isExperimentalMode,omitempty"` SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` diff --git a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java index 23e4f77b4..4254c04ec 100644 --- a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -130,6 +130,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setModels(config.getModels()); config.getEnableSessionTelemetry().ifPresent(request::setEnableSessionTelemetry); config.getEnableCitations().ifPresent(request::setEnableCitations); + config.getEnableFileChangeTracking().ifPresent(request::setEnableFileChangeTracking); request.setSessionLimits(config.getSessionLimits()); experimentalModeForMode(mode, config.getEnableExperimentalMode().orElse(null)) .ifPresent(request::setIsExperimentalMode); @@ -265,6 +266,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setModels(config.getModels()); config.getEnableSessionTelemetry().ifPresent(request::setEnableSessionTelemetry); config.getEnableCitations().ifPresent(request::setEnableCitations); + config.getEnableFileChangeTracking().ifPresent(request::setEnableFileChangeTracking); request.setSessionLimits(config.getSessionLimits()); experimentalModeForMode(mode, config.getEnableExperimentalMode().orElse(null)) .ifPresent(request::setIsExperimentalMode); diff --git a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 4c74e38ac..2eab977db 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -80,6 +80,9 @@ public final class CreateSessionRequest { @JsonProperty("enableCitations") private Boolean enableCitations; + @JsonProperty("enableFileChangeTracking") + private Boolean enableFileChangeTracking; + @JsonProperty("sessionLimits") private SessionLimitsConfig sessionLimits; @@ -434,6 +437,21 @@ public void setEnableCitations(boolean enableCitations) { this.enableCitations = enableCitations; } + /** Gets the file change tracking flag. @return the flag */ + public Boolean getEnableFileChangeTracking() { + return enableFileChangeTracking; + } + + /** + * Sets the file change tracking flag. + * + * @param enableFileChangeTracking + * the flag + */ + public void setEnableFileChangeTracking(boolean enableFileChangeTracking) { + this.enableFileChangeTracking = enableFileChangeTracking; + } + /** Gets the session limits. @return the session limits */ public SessionLimitsConfig getSessionLimits() { return sessionLimits; diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index 10641157b..a18803637 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -53,6 +53,7 @@ public class ResumeSessionConfig { private List models; private Boolean enableSessionTelemetry; private Boolean enableCitations; + private Boolean enableFileChangeTracking; private SessionLimitsConfig sessionLimits; private Boolean enableExperimentalMode; private Boolean skipCustomInstructions; @@ -453,6 +454,41 @@ public ResumeSessionConfig clearEnableCitations() { return this; } + /** + * Gets whether file change tracking is enabled for rewind and cumulative + * session diff. + * + * @return an {@link java.util.Optional} containing the setting, or + * {@link java.util.Optional#empty()} for the default + */ + @JsonIgnore + public Optional getEnableFileChangeTracking() { + return Optional.ofNullable(enableFileChangeTracking); + } + + /** + * Enables or disables file change tracking when the resumed session has a valid + * baseline. Earlier untracked changes cannot be reconstructed. + * + * @param enableFileChangeTracking + * whether to enable file change tracking + * @return this config instance for method chaining + */ + public ResumeSessionConfig setEnableFileChangeTracking(boolean enableFileChangeTracking) { + this.enableFileChangeTracking = enableFileChangeTracking; + return this; + } + + /** + * Clears the file change tracking setting, reverting to the default behavior. + * + * @return this instance for method chaining + */ + public ResumeSessionConfig clearEnableFileChangeTracking() { + this.enableFileChangeTracking = null; + return this; + } + /** * Gets the limits for this session's current accounting window. * @@ -1958,6 +1994,7 @@ public ResumeSessionConfig clone() { copy.models = this.models != null ? new ArrayList<>(this.models) : null; copy.enableSessionTelemetry = this.enableSessionTelemetry; copy.enableCitations = this.enableCitations; + copy.enableFileChangeTracking = this.enableFileChangeTracking; copy.sessionLimits = this.sessionLimits; copy.enableExperimentalMode = this.enableExperimentalMode; copy.reasoningEffort = this.reasoningEffort; diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 8c9d03ede..e52892477 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -82,6 +82,9 @@ public final class ResumeSessionRequest { @JsonProperty("enableCitations") private Boolean enableCitations; + @JsonProperty("enableFileChangeTracking") + private Boolean enableFileChangeTracking; + @JsonProperty("sessionLimits") private SessionLimitsConfig sessionLimits; @@ -439,6 +442,21 @@ public void setEnableCitations(boolean enableCitations) { this.enableCitations = enableCitations; } + /** Gets the file change tracking flag. @return the flag */ + public Boolean getEnableFileChangeTracking() { + return enableFileChangeTracking; + } + + /** + * Sets the file change tracking flag. + * + * @param enableFileChangeTracking + * the flag + */ + public void setEnableFileChangeTracking(boolean enableFileChangeTracking) { + this.enableFileChangeTracking = enableFileChangeTracking; + } + /** Gets the session limits. @return the session limits */ public SessionLimitsConfig getSessionLimits() { return sessionLimits; diff --git a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java index 3ccda690f..1127e6777 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -57,6 +57,7 @@ public class SessionConfig { private List models; private Boolean enableSessionTelemetry; private Boolean enableCitations; + private Boolean enableFileChangeTracking; private SessionLimitsConfig sessionLimits; private Boolean enableExperimentalMode; private Boolean skipCustomInstructions; @@ -556,6 +557,40 @@ public SessionConfig clearEnableCitations() { return this; } + /** + * Gets whether file change tracking is enabled for rewind and cumulative + * session diff. + * + * @return an {@link java.util.Optional} containing the setting, or + * {@link java.util.Optional#empty()} for the default + */ + @JsonIgnore + public Optional getEnableFileChangeTracking() { + return Optional.ofNullable(enableFileChangeTracking); + } + + /** + * Enables or disables file change tracking from the first turn. + * + * @param enableFileChangeTracking + * whether to enable file change tracking + * @return this config instance for method chaining + */ + public SessionConfig setEnableFileChangeTracking(boolean enableFileChangeTracking) { + this.enableFileChangeTracking = enableFileChangeTracking; + return this; + } + + /** + * Clears the file change tracking setting, reverting to the default behavior. + * + * @return this instance for method chaining + */ + public SessionConfig clearEnableFileChangeTracking() { + this.enableFileChangeTracking = null; + return this; + } + /** * Gets the limits for this session's current accounting window. * @@ -2098,6 +2133,7 @@ public SessionConfig clone() { copy.models = this.models != null ? new ArrayList<>(this.models) : null; copy.enableSessionTelemetry = this.enableSessionTelemetry; copy.enableCitations = this.enableCitations; + copy.enableFileChangeTracking = this.enableFileChangeTracking; copy.sessionLimits = this.sessionLimits; copy.enableExperimentalMode = this.enableExperimentalMode; copy.skipCustomInstructions = this.skipCustomInstructions; diff --git a/java/src/test/java/com/github/copilot/ConfigCloneTest.java b/java/src/test/java/com/github/copilot/ConfigCloneTest.java index c3f726ca1..b2b744eed 100644 --- a/java/src/test/java/com/github/copilot/ConfigCloneTest.java +++ b/java/src/test/java/com/github/copilot/ConfigCloneTest.java @@ -181,13 +181,14 @@ void sessionConfigSessionPolicyOptionsCloned() { var sessionLimits = new SessionLimitsConfig(30.0); var excludedAgents = new ArrayList<>(List.of("explore")); SessionConfig original = new SessionConfig().setExcludedBuiltInAgents(excludedAgents).setEnableCitations(true) - .setSessionLimits(sessionLimits); + .setEnableFileChangeTracking(true).setSessionLimits(sessionLimits); SessionConfig cloned = original.clone(); excludedAgents.add("task"); assertEquals(List.of("explore"), cloned.getExcludedBuiltInAgents()); assertTrue(cloned.getEnableCitations().orElse(false)); + assertTrue(cloned.getEnableFileChangeTracking().orElse(false)); assertSame(sessionLimits, cloned.getSessionLimits()); } @@ -235,13 +236,14 @@ void resumeSessionConfigSessionPolicyOptionsCloned() { var sessionLimits = new SessionLimitsConfig(30.0); var excludedAgents = new ArrayList<>(List.of("explore")); ResumeSessionConfig original = new ResumeSessionConfig().setExcludedBuiltInAgents(excludedAgents) - .setEnableCitations(true).setSessionLimits(sessionLimits); + .setEnableCitations(true).setEnableFileChangeTracking(true).setSessionLimits(sessionLimits); ResumeSessionConfig cloned = original.clone(); excludedAgents.add("task"); assertEquals(List.of("explore"), cloned.getExcludedBuiltInAgents()); assertTrue(cloned.getEnableCitations().orElse(false)); + assertTrue(cloned.getEnableFileChangeTracking().orElse(false)); assertSame(sessionLimits, cloned.getSessionLimits()); } diff --git a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index 329a7500a..0525786de 100644 --- a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -229,12 +229,13 @@ void testBuildCreateRequestForwardsExplicitMcpOAuthTokenStorage() { void testBuildCreateRequestForwardsSessionPolicyOptions() { var sessionLimits = new SessionLimitsConfig(30.0); var config = new SessionConfig().setExcludedBuiltInAgents(List.of("explore")).setEnableCitations(true) - .setSessionLimits(sessionLimits); + .setEnableFileChangeTracking(true).setSessionLimits(sessionLimits); CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config, "session-policy"); assertEquals(List.of("explore"), request.getExcludedBuiltInAgents()); assertTrue(request.getEnableCitations()); + assertTrue(request.getEnableFileChangeTracking()); assertSame(sessionLimits, request.getSessionLimits()); } @@ -434,12 +435,13 @@ void testBuildResumeRequestForwardsExplicitMcpOAuthTokenStorage() { void testBuildResumeRequestForwardsSessionPolicyOptions() { var sessionLimits = new SessionLimitsConfig(30.0); var config = new ResumeSessionConfig().setExcludedBuiltInAgents(List.of("explore")).setEnableCitations(true) - .setSessionLimits(sessionLimits); + .setEnableFileChangeTracking(true).setSessionLimits(sessionLimits); ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-policy", config); assertEquals(List.of("explore"), request.getExcludedBuiltInAgents()); assertTrue(request.getEnableCitations()); + assertTrue(request.getEnableFileChangeTracking()); assertSame(sessionLimits, request.getSessionLimits()); } diff --git a/java/src/test/java/com/github/copilot/e2e/RewindIT.java b/java/src/test/java/com/github/copilot/e2e/RewindIT.java new file mode 100644 index 000000000..eff48dde0 --- /dev/null +++ b/java/src/test/java/com/github/copilot/e2e/RewindIT.java @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.e2e; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.copilot.AllowCopilotExperimental; +import com.github.copilot.CopilotClient; +import com.github.copilot.CopilotSession; +import com.github.copilot.E2ETestContext; +import com.github.copilot.generated.AssistantMessageEvent; +import com.github.copilot.generated.rpc.HistoryRewindMode; +import com.github.copilot.generated.rpc.HistoryRewindOutcome; +import com.github.copilot.generated.rpc.SessionHistoryListRewindPointsResult; +import com.github.copilot.generated.rpc.SessionHistoryPreviewRewindParams; +import com.github.copilot.generated.rpc.SessionHistoryRewindParams; +import com.github.copilot.rpc.MessageOptions; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.SessionConfig; + +@AllowCopilotExperimental +class RewindIT { + + private static final String FILE_NAME = "rewind-sdk.txt"; + private static final String FILE_CONTENT = "SDK rewind content"; + + private static E2ETestContext ctx; + + @BeforeAll + static void setup() throws Exception { + ctx = E2ETestContext.create(); + } + + @AfterAll + static void teardown() throws Exception { + if (ctx != null) { + ctx.close(); + } + } + + @Test + void shouldRestoreTrackedFileAndConversation() throws Exception { + ctx.configureForTest("rewind", "should_restore_tracked_file_and_conversation"); + Path filePath = ctx.getWorkDir().resolve(FILE_NAME); + + try (CopilotClient client = ctx.createClient(); + CopilotSession session = client + .createSession( + new SessionConfig().setModel("claude-sonnet-4.5").setEnableFileChangeTracking(true) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(30, TimeUnit.SECONDS)) { + AssistantMessageEvent response = session + .sendAndWait(new MessageOptions().setPrompt( + "Use the create tool to create " + FILE_NAME + " containing exactly " + FILE_CONTENT + + ". After the tool succeeds, reply with exactly SDK_REWIND_DONE."), + 30_000) + .get(60, TimeUnit.SECONDS); + + assertNotNull(response); + assertEquals("SDK_REWIND_DONE", response.getData().content()); + assertEquals(FILE_CONTENT, Files.readString(filePath)); + + SessionHistoryListRewindPointsResult rewindPoints = waitForRewindPoints(session); + assertTrue(Boolean.TRUE.equals(rewindPoints.fileChangeTrackingEnabled())); + assertEquals(1, rewindPoints.points().size()); + var rewindPoint = rewindPoints.points().get(0); + assertTrue(Boolean.TRUE.equals(rewindPoint.canRestoreFiles())); + assertEquals(1L, rewindPoint.fileCount()); + + var preview = session.getRpc().history + .previewRewind(new SessionHistoryPreviewRewindParams(null, rewindPoint.eventId())) + .get(10, TimeUnit.SECONDS); + assertTrue(Boolean.TRUE.equals(preview.available())); + assertEquals(1, preview.files().size()); + assertSamePath(filePath, preview.files().get(0).path()); + + var rewind = session.getRpc().history.rewind(new SessionHistoryRewindParams(null, rewindPoint.eventId(), + HistoryRewindMode.CONVERSATION_AND_FILES)).get(10, TimeUnit.SECONDS); + assertEquals(HistoryRewindOutcome.SUCCESS, rewind.outcome()); + assertTrue(rewind.eventsRemoved() != null && rewind.eventsRemoved() > 0); + assertEquals(1, rewind.restoredFiles().size()); + assertSamePath(filePath, rewind.restoredFiles().get(0)); + assertFalse(Files.exists(filePath)); + + var events = session.getMessages().get(10, TimeUnit.SECONDS); + assertTrue(events.stream().noneMatch(event -> event.getId().toString().equals(rewindPoint.eventId()))); + } + } + + private static SessionHistoryListRewindPointsResult waitForRewindPoints(CopilotSession session) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + SessionHistoryListRewindPointsResult result; + do { + result = session.getRpc().history.listRewindPoints().get(10, TimeUnit.SECONDS); + if (result.unavailableReason() == null) { + return result; + } + TimeUnit.MILLISECONDS.sleep(100); + } while (System.nanoTime() < deadline); + + assertNull(result.unavailableReason(), "Timed out waiting for rewind points to become available"); + return result; + } + + private static void assertSamePath(Path expected, String actual) { + String expectedPath = expected.toAbsolutePath().normalize().toString(); + String actualPath = Path.of(actual).toAbsolutePath().normalize().toString(); + if (System.getProperty("os.name").startsWith("Windows")) { + assertTrue(expectedPath.equalsIgnoreCase(actualPath)); + } else { + assertEquals(expectedPath, actualPath); + } + } +} diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index c30b2207b..1182b4106 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1561,6 +1561,7 @@ export class CopilotClient { models: config.models, enableSessionTelemetry: config.enableSessionTelemetry, enableCitations: config.enableCitations, + enableFileChangeTracking: config.enableFileChangeTracking, sessionLimits: config.sessionLimits, modelCapabilities: config.modelCapabilities, largeOutput: toWireLargeOutput(config.largeOutput), @@ -1781,6 +1782,7 @@ export class CopilotClient { enableSessionTelemetry: config.enableSessionTelemetry, excludedBuiltinAgents: config.excludedBuiltinAgents, enableCitations: config.enableCitations, + enableFileChangeTracking: config.enableFileChangeTracking, sessionLimits: config.sessionLimits, tools: config.tools?.map((tool) => ({ name: tool.name, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 3a5f7714b..2567d7d31 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -2364,6 +2364,14 @@ export interface SessionConfigBase { */ enableCitations?: boolean; + /** + * Opt in to capturing file changes for session rewind and cumulative session + * diff. On create, capture starts with the first turn. On resume, this can + * enable tracking only when the session still has a valid baseline; it cannot + * reconstruct changes from earlier untracked turns. + */ + enableFileChangeTracking?: boolean; + /** * Limits applied to this session's current accounting window. * diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 01a97e980..254c126ed 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -792,12 +792,14 @@ describe("CopilotClient", () => { const session = await client.createSession({ onPermissionRequest: approveAll, enableCitations: true, + enableFileChangeTracking: true, excludedBuiltinAgents: ["explore"], sessionLimits: { maxAiCredits: 30 }, }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll, enableCitations: false, + enableFileChangeTracking: false, excludedBuiltinAgents: ["task"], sessionLimits: { maxAiCredits: 15 }, }); @@ -809,9 +811,11 @@ describe("CopilotClient", () => { ([method]) => method === "session.resume" )![1] as any; expect(createPayload.enableCitations).toBe(true); + expect(createPayload.enableFileChangeTracking).toBe(true); expect(createPayload.excludedBuiltinAgents).toEqual(["explore"]); expect(createPayload.sessionLimits).toEqual({ maxAiCredits: 30 }); expect(resumePayload.enableCitations).toBe(false); + expect(resumePayload.enableFileChangeTracking).toBe(false); expect(resumePayload.excludedBuiltinAgents).toEqual(["task"]); expect(resumePayload.sessionLimits).toEqual({ maxAiCredits: 15 }); }); diff --git a/nodejs/test/e2e/rewind.e2e.test.ts b/nodejs/test/e2e/rewind.e2e.test.ts new file mode 100644 index 000000000..920ffed19 --- /dev/null +++ b/nodejs/test/e2e/rewind.e2e.test.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +const FILE_NAME = "rewind-sdk.txt"; +const FILE_CONTENT = "SDK rewind content"; + +function expectSamePath(actual: string, expected: string): void { + const actualPath = resolve(actual); + const expectedPath = resolve(expected); + if (process.platform === "win32") { + expect(actualPath.toLowerCase()).toBe(expectedPath.toLowerCase()); + } else { + expect(actualPath).toBe(expectedPath); + } +} + +describe("Rewind", async () => { + const { copilotClient: client, workDir } = await createSdkTestContext(); + + it("should restore tracked file and conversation", async () => { + const filePath = join(workDir, FILE_NAME); + const session = await client.createSession({ + model: "claude-sonnet-4.5", + enableFileChangeTracking: true, + onPermissionRequest: approveAll, + }); + + try { + const response = await session.sendAndWait({ + prompt: `Use the create tool to create ${FILE_NAME} containing exactly ${FILE_CONTENT}. After the tool succeeds, reply with exactly SDK_REWIND_DONE.`, + }); + + expect(response?.data.content).toBe("SDK_REWIND_DONE"); + expect(existsSync(filePath)).toBe(true); + expect(readFileSync(filePath, "utf8")).toBe(FILE_CONTENT); + + let rewindPoints = await session.rpc.history.listRewindPoints(); + const deadline = Date.now() + 10_000; + while (rewindPoints.unavailableReason && Date.now() < deadline) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + rewindPoints = await session.rpc.history.listRewindPoints(); + } + + expect(rewindPoints.unavailableReason).toBeUndefined(); + expect(rewindPoints.fileChangeTrackingEnabled).toBe(true); + expect(rewindPoints.points).toHaveLength(1); + const rewindPoint = rewindPoints.points[0]; + expect(rewindPoint.canRestoreFiles).toBe(true); + expect(rewindPoint.fileCount).toBe(1); + + const preview = await session.rpc.history.previewRewind({ + eventId: rewindPoint.eventId, + }); + expect(preview.available).toBe(true); + expect(preview.files).toHaveLength(1); + expectSamePath(preview.files[0].path, filePath); + + const rewind = await session.rpc.history.rewind({ + eventId: rewindPoint.eventId, + mode: "conversation-and-files", + }); + expect(rewind.outcome).toBe("success"); + expect(rewind.eventsRemoved).toBeGreaterThan(0); + expect(rewind.restoredFiles).toHaveLength(1); + expectSamePath(rewind.restoredFiles[0], filePath); + expect(existsSync(filePath)).toBe(false); + + const events = await session.getEvents(); + expect(events.some((event) => event.id === rewindPoint.eventId)).toBe(false); + } finally { + await session.disconnect(); + } + }); +}); diff --git a/python/copilot/client.py b/python/copilot/client.py index 21ceb6eee..415f44ef5 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2094,6 +2094,7 @@ async def create_session( models: list[ProviderModelConfig] | None = None, enable_session_telemetry: bool | None = None, enable_citations: bool | None = None, + enable_file_change_tracking: bool | None = None, excluded_builtin_agents: list[str] | None = None, session_limits: SessionLimitsConfig | None = None, skip_custom_instructions: bool | None = None, @@ -2210,6 +2211,8 @@ async def create_session( OpenTelemetry configuration. enable_citations: **Experimental.** Enables native model citations for supported providers. + enable_file_change_tracking: Opts in to capturing file changes from the + first turn for session rewind and cumulative session diff. excluded_builtin_agents: Built-in agent names to exclude from the session. Excluded built-in agents are hidden from discovery and cannot be selected or invoked unless a custom agent with the same @@ -2500,6 +2503,8 @@ async def create_session( payload["enableSessionTelemetry"] = enable_session_telemetry if enable_citations is not None: payload["enableCitations"] = enable_citations + if enable_file_change_tracking is not None: + payload["enableFileChangeTracking"] = enable_file_change_tracking if excluded_builtin_agents is not None: payload["excludedBuiltinAgents"] = excluded_builtin_agents if session_limits is not None: @@ -2817,6 +2822,7 @@ async def resume_session( models: list[ProviderModelConfig] | None = None, enable_session_telemetry: bool | None = None, enable_citations: bool | None = None, + enable_file_change_tracking: bool | None = None, excluded_builtin_agents: list[str] | None = None, session_limits: SessionLimitsConfig | None = None, skip_custom_instructions: bool | None = None, @@ -2934,6 +2940,9 @@ async def resume_session( OpenTelemetry configuration. enable_citations: **Experimental.** Enables native model citations for supported providers. + enable_file_change_tracking: Opts in to capturing file changes for + session rewind and cumulative session diff when the resumed session + has a valid baseline. Earlier untracked changes cannot be reconstructed. excluded_builtin_agents: Built-in agent names to exclude from the resumed session. Excluded built-in agents are hidden from discovery and cannot be selected or invoked unless a custom agent with the @@ -3139,6 +3148,8 @@ async def resume_session( payload["enableSessionTelemetry"] = enable_session_telemetry if enable_citations is not None: payload["enableCitations"] = enable_citations + if enable_file_change_tracking is not None: + payload["enableFileChangeTracking"] = enable_file_change_tracking if excluded_builtin_agents is not None: payload["excludedBuiltinAgents"] = excluded_builtin_agents if session_limits is not None: diff --git a/python/e2e/test_rewind_e2e.py b/python/e2e/test_rewind_e2e.py new file mode 100644 index 000000000..e3db37915 --- /dev/null +++ b/python/e2e/test_rewind_e2e.py @@ -0,0 +1,88 @@ +"""E2E coverage for rewinding tracked files and conversation history.""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path + +import pytest + +from copilot.rpc import ( + HistoryPreviewRewindRequest, + HistoryRewindMode, + HistoryRewindOutcome, + HistoryRewindRequest, +) +from copilot.session import PermissionHandler + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + +FILE_NAME = "rewind-sdk.txt" +FILE_CONTENT = "SDK rewind content" + + +def _same_path(left: str | Path, right: str | Path) -> bool: + return os.path.normcase(os.path.abspath(left)) == os.path.normcase(os.path.abspath(right)) + + +class TestRewind: + async def test_should_restore_tracked_file_and_conversation(self, ctx: E2ETestContext): + file_path = Path(ctx.work_dir) / FILE_NAME + session = await ctx.client.create_session( + model="claude-sonnet-4.5", + enable_file_change_tracking=True, + on_permission_request=PermissionHandler.approve_all, + ) + + try: + response = await session.send_and_wait( + f"Use the create tool to create {FILE_NAME} containing exactly {FILE_CONTENT}. " + "After the tool succeeds, reply with exactly SDK_REWIND_DONE." + ) + + assert response is not None + assert response.data.content == "SDK_REWIND_DONE" + assert file_path.read_text(encoding="utf-8") == FILE_CONTENT + + rewind_points = await session.rpc.history.list_rewind_points() + deadline = asyncio.get_running_loop().time() + 10 + while ( + rewind_points.unavailable_reason is not None + and asyncio.get_running_loop().time() < deadline + ): + await asyncio.sleep(0.1) + rewind_points = await session.rpc.history.list_rewind_points() + + assert rewind_points.unavailable_reason is None + assert rewind_points.file_change_tracking_enabled + assert len(rewind_points.points) == 1 + rewind_point = rewind_points.points[0] + assert rewind_point.can_restore_files + assert rewind_point.file_count == 1 + + preview = await session.rpc.history.preview_rewind( + HistoryPreviewRewindRequest(event_id=rewind_point.event_id) + ) + assert preview.available + assert len(preview.files) == 1 + assert _same_path(preview.files[0].path, file_path) + + rewind = await session.rpc.history.rewind( + HistoryRewindRequest( + event_id=rewind_point.event_id, + mode=HistoryRewindMode.CONVERSATION_AND_FILES, + ) + ) + assert rewind.outcome == HistoryRewindOutcome.SUCCESS + assert rewind.events_removed is not None and rewind.events_removed > 0 + assert len(rewind.restored_files) == 1 + assert _same_path(rewind.restored_files[0], file_path) + assert not file_path.exists() + + events = await session.get_events() + assert all(str(event.id) != rewind_point.event_id for event in events) + finally: + await session.disconnect() diff --git a/python/test_client.py b/python/test_client.py index 2375bc98a..893b82af1 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -980,6 +980,7 @@ async def mock_request(method, params, **kwargs): session = await client.create_session( on_permission_request=PermissionHandler.approve_all, enable_citations=True, + enable_file_change_tracking=True, excluded_builtin_agents=["explore"], session_limits={"max_ai_credits": 30}, ) @@ -987,14 +988,17 @@ async def mock_request(method, params, **kwargs): session.session_id, on_permission_request=PermissionHandler.approve_all, enable_citations=False, + enable_file_change_tracking=False, excluded_builtin_agents=["task"], session_limits={"max_ai_credits": 15}, ) assert captured["session.create"]["enableCitations"] is True + assert captured["session.create"]["enableFileChangeTracking"] is True assert captured["session.create"]["excludedBuiltinAgents"] == ["explore"] assert captured["session.create"]["sessionLimits"] == {"maxAiCredits": 30} assert captured["session.resume"]["enableCitations"] is False + assert captured["session.resume"]["enableFileChangeTracking"] is False assert captured["session.resume"]["excludedBuiltinAgents"] == ["task"] assert captured["session.resume"]["sessionLimits"] == {"maxAiCredits": 15} finally: diff --git a/rust/src/types.rs b/rust/src/types.rs index d3c4faa16..1946ded08 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -2093,6 +2093,9 @@ pub struct SessionConfig { pub enable_session_telemetry: Option, /// **Experimental.** Enables native model citations for supported providers. pub enable_citations: Option, + /// Opts in to capturing file changes from the first turn for session rewind + /// and cumulative session diff. + pub enable_file_change_tracking: Option, /// **Experimental.** Limits applied to this session's current accounting window. pub session_limits: Option, /// Per-property overrides for model capabilities, deep-merged over @@ -2283,6 +2286,10 @@ impl std::fmt::Debug for SessionConfig { .field("capi", &self.capi) .field("enable_session_telemetry", &self.enable_session_telemetry) .field("enable_citations", &self.enable_citations) + .field( + "enable_file_change_tracking", + &self.enable_file_change_tracking, + ) .field("session_limits", &self.session_limits) .field("model_capabilities", &self.model_capabilities) .field("memory", &self.memory) @@ -2402,6 +2409,7 @@ impl Default for SessionConfig { models: None, enable_session_telemetry: None, enable_citations: None, + enable_file_change_tracking: None, session_limits: None, model_capabilities: None, memory: None, @@ -2566,6 +2574,7 @@ impl SessionConfig { models: self.models, enable_session_telemetry: self.enable_session_telemetry, enable_citations: self.enable_citations, + enable_file_change_tracking: self.enable_file_change_tracking, session_limits: self.session_limits, model_capabilities: self.model_capabilities, memory: self.memory, @@ -3078,6 +3087,13 @@ impl SessionConfig { self } + /// Opt in to capturing file changes from the first turn for session rewind + /// and cumulative session diff. + pub fn with_enable_file_change_tracking(mut self, enable: bool) -> Self { + self.enable_file_change_tracking = Some(enable); + self + } + /// **Experimental.** Set limits for this session's current accounting window. pub fn with_session_limits(mut self, limits: SessionLimitsConfig) -> Self { self.session_limits = Some(limits); @@ -3369,6 +3385,10 @@ pub struct ResumeSessionConfig { pub enable_session_telemetry: Option, /// **Experimental.** Enables native model citations for supported providers. pub enable_citations: Option, + /// Opts in to capturing file changes for session rewind and cumulative + /// session diff when the resumed session has a valid baseline. Earlier + /// untracked changes cannot be reconstructed. + pub enable_file_change_tracking: Option, /// **Experimental.** Limits applied to this session's current accounting window. pub session_limits: Option, /// Per-property model capability overrides on resume. @@ -3532,6 +3552,10 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("capi", &self.capi) .field("enable_session_telemetry", &self.enable_session_telemetry) .field("enable_citations", &self.enable_citations) + .field( + "enable_file_change_tracking", + &self.enable_file_change_tracking, + ) .field("session_limits", &self.session_limits) .field("model_capabilities", &self.model_capabilities) .field("memory", &self.memory) @@ -3695,6 +3719,7 @@ impl ResumeSessionConfig { models: self.models, enable_session_telemetry: self.enable_session_telemetry, enable_citations: self.enable_citations, + enable_file_change_tracking: self.enable_file_change_tracking, session_limits: self.session_limits, model_capabilities: self.model_capabilities, memory: self.memory, @@ -3791,6 +3816,7 @@ impl ResumeSessionConfig { models: None, enable_session_telemetry: None, enable_citations: None, + enable_file_change_tracking: None, session_limits: None, model_capabilities: None, memory: None, @@ -4280,6 +4306,13 @@ impl ResumeSessionConfig { self } + /// Opt in to capturing file changes for session rewind and cumulative + /// session diff when the resumed session has a valid baseline. + pub fn with_enable_file_change_tracking(mut self, enable: bool) -> Self { + self.enable_file_change_tracking = Some(enable); + self + } + /// **Experimental.** Set limits for this session's current accounting window. pub fn with_session_limits(mut self, limits: SessionLimitsConfig) -> Self { self.session_limits = Some(limits); diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 53ea1c448..21b61a7f9 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -154,6 +154,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub enable_citations: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub enable_file_change_tracking: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub session_limits: Option, #[serde(skip_serializing_if = "Option::is_none")] pub model_capabilities: Option, @@ -302,6 +304,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub enable_citations: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub enable_file_change_tracking: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub session_limits: Option, #[serde(skip_serializing_if = "Option::is_none")] pub model_capabilities: Option, diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 3a698abd1..03723dfb1 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -66,6 +66,8 @@ mod permissions; mod pre_mcp_tool_call_hook; #[path = "e2e/provider_endpoint.rs"] mod provider_endpoint; +#[path = "e2e/rewind.rs"] +mod rewind; #[path = "e2e/rpc_additional_edge_cases.rs"] mod rpc_additional_edge_cases; #[path = "e2e/rpc_agent.rs"] diff --git a/rust/tests/e2e/rewind.rs b/rust/tests/e2e/rewind.rs new file mode 100644 index 000000000..990c45091 --- /dev/null +++ b/rust/tests/e2e/rewind.rs @@ -0,0 +1,131 @@ +use std::path::Path; +use std::time::Duration; + +use github_copilot_sdk::rpc::{ + HistoryListRewindPointsResult, HistoryPreviewRewindRequest, HistoryRewindMode, + HistoryRewindOutcome, HistoryRewindRequest, +}; + +use super::support::assistant_message_content; + +const FILE_NAME: &str = "rewind-sdk.txt"; +const FILE_CONTENT: &str = "SDK rewind content"; + +#[tokio::test] +async fn should_restore_tracked_file_and_conversation() { + super::support::with_shared_e2e_context( + &E2E, + "rewind", + "should_restore_tracked_file_and_conversation", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let file_path = ctx.work_dir().join(FILE_NAME); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_model("claude-sonnet-4.5") + .with_enable_file_change_tracking(true), + ) + .await + .expect("create session"); + + let response = session + .send_and_wait(format!( + "Use the create tool to create {FILE_NAME} containing exactly \ + {FILE_CONTENT}. After the tool succeeds, reply with exactly \ + SDK_REWIND_DONE." + )) + .await + .expect("send rewind setup prompt") + .expect("assistant message"); + assert_eq!(assistant_message_content(&response), "SDK_REWIND_DONE"); + assert_eq!( + std::fs::read_to_string(&file_path).expect("read tracked file"), + FILE_CONTENT + ); + + let rewind_points = wait_for_rewind_points(&session).await; + assert!(rewind_points.file_change_tracking_enabled); + assert_eq!(rewind_points.points.len(), 1); + let rewind_point = &rewind_points.points[0]; + assert!(rewind_point.can_restore_files); + assert_eq!(rewind_point.file_count, 1); + + let preview = session + .rpc() + .history() + .preview_rewind(HistoryPreviewRewindRequest { + event_id: rewind_point.event_id.clone(), + }) + .await + .expect("preview rewind"); + assert!(preview.available); + assert_eq!(preview.files.len(), 1); + assert_same_path(&file_path, Path::new(&preview.files[0].path)); + + let rewind = session + .rpc() + .history() + .rewind(HistoryRewindRequest { + event_id: rewind_point.event_id.clone(), + mode: HistoryRewindMode::ConversationAndFiles, + }) + .await + .expect("rewind conversation and files"); + assert_eq!(rewind.outcome, HistoryRewindOutcome::Success); + assert!(rewind.events_removed.is_some_and(|count| count > 0)); + assert_eq!(rewind.restored_files.len(), 1); + assert_same_path(&file_path, Path::new(&rewind.restored_files[0])); + assert!(!file_path.exists()); + + let events = session.get_events().await.expect("get events after rewind"); + assert!(events.iter().all(|event| event.id != rewind_point.event_id)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +async fn wait_for_rewind_points( + session: &github_copilot_sdk::session::Session, +) -> HistoryListRewindPointsResult { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + let result = session + .rpc() + .history() + .list_rewind_points() + .await + .expect("list rewind points"); + if result.unavailable_reason.is_none() { + return result; + } + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for rewind points" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +fn assert_same_path(expected: &Path, actual: &Path) { + let expected = expected.to_string_lossy(); + let actual = actual.to_string_lossy(); + if cfg!(windows) { + let expected = expected.replace('\\', "/"); + let actual = actual.replace('\\', "/"); + assert!( + expected.eq_ignore_ascii_case(&actual), + "expected path {expected:?}, got {actual:?}" + ); + } else { + assert_eq!(expected, actual); + } +} + +static E2E: super::support::SharedE2eGroup = super::support::SharedE2eGroup::standard("rewind", 1); diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 727911081..83a8489f2 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -641,6 +641,7 @@ async fn create_session_sends_new_session_options() { SessionConfig::default() .with_excluded_builtin_agents(["explore"]) .with_enable_citations(true) + .with_enable_file_change_tracking(true) .with_session_limits(SessionLimitsConfig { max_ai_credits: Some(30.0), }), @@ -657,6 +658,7 @@ async fn create_session_sends_new_session_options() { serde_json::json!(["explore"]) ); assert_eq!(request["params"]["enableCitations"], true); + assert_eq!(request["params"]["enableFileChangeTracking"], true); assert_eq!(request["params"]["sessionLimits"]["maxAiCredits"], 30.0); let id = request["id"].as_u64().unwrap(); @@ -685,6 +687,7 @@ async fn resume_session_sends_new_session_options() { ResumeSessionConfig::new(SessionId::from("session-options")) .with_excluded_builtin_agents(["task"]) .with_enable_citations(false) + .with_enable_file_change_tracking(false) .with_session_limits(SessionLimitsConfig { max_ai_credits: Some(15.0), }), @@ -702,6 +705,7 @@ async fn resume_session_sends_new_session_options() { serde_json::json!(["task"]) ); assert_eq!(request["params"]["enableCitations"], false); + assert_eq!(request["params"]["enableFileChangeTracking"], false); assert_eq!(request["params"]["sessionLimits"]["maxAiCredits"], 15.0); server_respond_create(&mut server_write, &request, "session-options").await; diff --git a/test/snapshots/rewind/should_restore_tracked_file_and_conversation.yaml b/test/snapshots/rewind/should_restore_tracked_file_and_conversation.yaml new file mode 100644 index 000000000..2ef3733e0 --- /dev/null +++ b/test/snapshots/rewind/should_restore_tracked_file_and_conversation.yaml @@ -0,0 +1,21 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use the create tool to create rewind-sdk.txt containing exactly SDK rewind content. After the tool succeeds, + reply with exactly SDK_REWIND_DONE. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: create + arguments: '{"path":"${workdir}/rewind-sdk.txt","file_text":"SDK rewind content"}' + - role: tool + tool_call_id: toolcall_0 + content: Created file ${workdir}/rewind-sdk.txt with 18 characters + - role: assistant + content: SDK_REWIND_DONE