diff --git a/dotnet/README.md b/dotnet/README.md index 5ac20b21..e176da40 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -97,10 +97,11 @@ Create a new conversation session. - `ExcludedTools` - List of tool names to disable - `Provider` - Custom API provider configuration (BYOK) - `Streaming` - Enable streaming of response chunks (default: false) +- `InfiniteSessions` - Configure automatic context compaction (see below) ##### `ResumeSessionAsync(string sessionId, ResumeSessionConfig? config = null): Task` -Resume an existing session. +Resume an existing session. Returns the session with `WorkspacePath` populated if infinite sessions were enabled. ##### `PingAsync(string? message = null): Task` @@ -127,6 +128,7 @@ Represents a single conversation session. #### Properties - `SessionId` - The unique identifier for this session +- `WorkspacePath` - Path to the session workspace directory when infinite sessions are enabled. Contains `checkpoints/`, `plan.md`, and `files/` subdirectories. Null if infinite sessions are disabled. #### Methods @@ -281,6 +283,46 @@ When `Streaming = true`: Note: `AssistantMessageEvent` and `AssistantReasoningEvent` (final events) are always sent regardless of streaming setting. +## Infinite Sessions + +By default, sessions use **infinite sessions** which automatically manage context window limits through background compaction and persist state to a workspace directory. + +```csharp +// Default: infinite sessions enabled with default thresholds +var session = await client.CreateSessionAsync(new SessionConfig +{ + Model = "gpt-5" +}); + +// Access the workspace path for checkpoints and files +Console.WriteLine(session.WorkspacePath); +// => ~/.copilot/session-state/{sessionId}/ + +// Custom thresholds +var session = await client.CreateSessionAsync(new SessionConfig +{ + Model = "gpt-5", + InfiniteSessions = new InfiniteSessionConfig + { + Enabled = true, + BackgroundCompactionThreshold = 0.80, // Start compacting at 80% context usage + BufferExhaustionThreshold = 0.95 // Block at 95% until compaction completes + } +}); + +// Disable infinite sessions +var session = await client.CreateSessionAsync(new SessionConfig +{ + Model = "gpt-5", + InfiniteSessions = new InfiniteSessionConfig { Enabled = false } +}); +``` + +When enabled, sessions emit compaction events: + +- `SessionCompactionStartEvent` - Background compaction started +- `SessionCompactionCompleteEvent` - Compaction finished (includes token counts) + ## Advanced Usage ### Manual Server Control diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 912ab93e..ef7982cb 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -344,12 +344,13 @@ public async Task CreateSessionAsync(SessionConfig? config = nul config?.CustomAgents, config?.ConfigDir, config?.SkillDirectories, - config?.DisabledSkills); + config?.DisabledSkills, + config?.InfiniteSessions); var response = await connection.Rpc.InvokeWithCancellationAsync( "session.create", [request], cancellationToken); - var session = new CopilotSession(response.SessionId, connection.Rpc); + var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath); session.RegisterTools(config?.Tools ?? []); if (config?.OnPermissionRequest != null) { @@ -406,7 +407,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes var response = await connection.Rpc.InvokeWithCancellationAsync( "session.resume", [request], cancellationToken); - var session = new CopilotSession(response.SessionId, connection.Rpc); + var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath); session.RegisterTools(config?.Tools ?? []); if (config?.OnPermissionRequest != null) { @@ -991,7 +992,8 @@ internal record CreateSessionRequest( List? CustomAgents, string? ConfigDir, List? SkillDirectories, - List? DisabledSkills); + List? DisabledSkills, + InfiniteSessionConfig? InfiniteSessions); internal record ToolDefinition( string Name, @@ -1003,7 +1005,8 @@ public static ToolDefinition FromAIFunction(AIFunction function) } internal record CreateSessionResponse( - string SessionId); + string SessionId, + string? WorkspacePath); internal record ResumeSessionRequest( string SessionId, @@ -1017,7 +1020,8 @@ internal record ResumeSessionRequest( List? DisabledSkills); internal record ResumeSessionResponse( - string SessionId); + string SessionId, + string? WorkspacePath); internal record GetLastSessionIdResponse( string? SessionId); diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index e554e034..f1e47df8 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -55,18 +55,29 @@ public partial class CopilotSession : IAsyncDisposable /// A string that uniquely identifies this session. public string SessionId { get; } + /// + /// Gets the path to the session workspace directory when infinite sessions are enabled. + /// + /// + /// The path to the workspace containing checkpoints/, plan.md, and files/ subdirectories, + /// or null if infinite sessions are disabled. + /// + public string? WorkspacePath { get; } + /// /// Initializes a new instance of the class. /// /// The unique identifier for this session. /// The JSON-RPC connection to the Copilot CLI. + /// The workspace path if infinite sessions are enabled. /// /// This constructor is internal. Use to create sessions. /// - internal CopilotSession(string sessionId, JsonRpc rpc) + internal CopilotSession(string sessionId, JsonRpc rpc, string? workspacePath = null) { SessionId = sessionId; _rpc = rpc; + WorkspacePath = workspacePath; } /// diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 6abbb42b..24b4fc2e 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -298,6 +298,36 @@ public class CustomAgentConfig public bool? Infer { get; set; } } +/// +/// Configuration for infinite sessions with automatic context compaction and workspace persistence. +/// When enabled, sessions automatically manage context window limits through background compaction +/// and persist state to a workspace directory. +/// +public class InfiniteSessionConfig +{ + /// + /// Whether infinite sessions are enabled. Default: true + /// + [JsonPropertyName("enabled")] + public bool? Enabled { get; set; } + + /// + /// Context utilization threshold (0.0-1.0) at which background compaction starts. + /// Compaction runs asynchronously, allowing the session to continue processing. + /// Default: 0.80 + /// + [JsonPropertyName("backgroundCompactionThreshold")] + public double? BackgroundCompactionThreshold { get; set; } + + /// + /// Context utilization threshold (0.0-1.0) at which the session blocks until compaction completes. + /// This prevents context overflow when compaction hasn't finished in time. + /// Default: 0.95 + /// + [JsonPropertyName("bufferExhaustionThreshold")] + public double? BufferExhaustionThreshold { get; set; } +} + public class SessionConfig { public string? SessionId { get; set; } @@ -348,6 +378,12 @@ public class SessionConfig /// List of skill names to disable. /// public List? DisabledSkills { get; set; } + + /// + /// Infinite session configuration for persistent workspaces and automatic compaction. + /// When enabled (default), sessions automatically manage context limits and persist state. + /// + public InfiniteSessionConfig? InfiniteSessions { get; set; } } public class ResumeSessionConfig diff --git a/dotnet/test/CompactionTests.cs b/dotnet/test/CompactionTests.cs new file mode 100644 index 00000000..af76508c --- /dev/null +++ b/dotnet/test/CompactionTests.cs @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Runtime.InteropServices; +using GitHub.Copilot.SDK.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.SDK.Test; + +public class CompactionTests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "compaction", output) +{ + [Fact] + public async Task Should_Trigger_Compaction_With_Low_Threshold_And_Emit_Events() + { + // Create session with very low compaction thresholds to trigger compaction quickly + var session = await Client.CreateSessionAsync(new SessionConfig + { + InfiniteSessions = new InfiniteSessionConfig + { + Enabled = true, + // Trigger background compaction at 0.5% context usage (~1000 tokens) + BackgroundCompactionThreshold = 0.005, + // Block at 1% to ensure compaction runs + BufferExhaustionThreshold = 0.01 + } + }); + + var compactionStartEvents = new List(); + var compactionCompleteEvents = new List(); + + session.On(evt => + { + if (evt is SessionCompactionStartEvent startEvt) + { + compactionStartEvents.Add(startEvt); + } + if (evt is SessionCompactionCompleteEvent completeEvt) + { + compactionCompleteEvents.Add(completeEvt); + } + }); + + // Send multiple messages to fill up the context window + await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Tell me a long story about a dragon. Be very detailed." + }); + await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Continue the story with more details about the dragon's castle." + }); + await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Now describe the dragon's treasure in great detail." + }); + + // Should have triggered compaction at least once + Assert.True(compactionStartEvents.Count >= 1, "Expected at least 1 compaction_start event"); + Assert.True(compactionCompleteEvents.Count >= 1, "Expected at least 1 compaction_complete event"); + + // Compaction should have succeeded + var lastComplete = compactionCompleteEvents[^1]; + Assert.True(lastComplete.Data.Success, "Expected compaction to succeed"); + + // Should have removed some tokens + if (lastComplete.Data.TokensRemoved.HasValue) + { + Assert.True(lastComplete.Data.TokensRemoved > 0, "Expected tokensRemoved > 0"); + } + + // Verify the session still works after compaction + var answer = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What was the story about?" + }); + Assert.NotNull(answer); + Assert.NotNull(answer!.Data.Content); + // Should remember it was about a dragon (context preserved via summary) + Assert.Contains("dragon", answer.Data.Content.ToLower()); + } + + [Fact] + public async Task Should_Not_Emit_Compaction_Events_When_Infinite_Sessions_Disabled() + { + var session = await Client.CreateSessionAsync(new SessionConfig + { + InfiniteSessions = new InfiniteSessionConfig + { + Enabled = false + } + }); + + var compactionEvents = new List(); + + session.On(evt => + { + if (evt is SessionCompactionStartEvent or SessionCompactionCompleteEvent) + { + compactionEvents.Add(evt); + } + }); + + await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" }); + + // Should not have any compaction events when disabled + Assert.Empty(compactionEvents); + } +} diff --git a/dotnet/test/Harness/CapiProxy.cs b/dotnet/test/Harness/CapiProxy.cs index 18e21a46..c5a146ec 100644 --- a/dotnet/test/Harness/CapiProxy.cs +++ b/dotnet/test/Harness/CapiProxy.cs @@ -78,7 +78,9 @@ async Task StartCoreAsync() } }); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + // Use longer timeout on Windows due to slower process startup + var timeoutSeconds = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? 30 : 10; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); cts.Token.Register(() => tcs.TrySetException(new TimeoutException("Timeout waiting for proxy"))); return await tcs.Task; diff --git a/go/README.md b/go/README.md index b57fc3c9..1352d9a3 100644 --- a/go/README.md +++ b/go/README.md @@ -287,6 +287,44 @@ When `Streaming: true`: Note: `assistant.message` and `assistant.reasoning` (final events) are always sent regardless of streaming setting. +## Infinite Sessions + +By default, sessions use **infinite sessions** which automatically manage context window limits through background compaction and persist state to a workspace directory. + +```go +// Default: infinite sessions enabled with default thresholds +session, _ := client.CreateSession(&copilot.SessionConfig{ + Model: "gpt-5", +}) + +// Access the workspace path for checkpoints and files +fmt.Println(session.WorkspacePath()) +// => ~/.copilot/session-state/{sessionId}/ + +// Custom thresholds +session, _ := client.CreateSession(&copilot.SessionConfig{ + Model: "gpt-5", + InfiniteSessions: &copilot.InfiniteSessionConfig{ + Enabled: copilot.Bool(true), + BackgroundCompactionThreshold: copilot.Float64(0.80), // Start compacting at 80% context usage + BufferExhaustionThreshold: copilot.Float64(0.95), // Block at 95% until compaction completes + }, +}) + +// Disable infinite sessions +session, _ := client.CreateSession(&copilot.SessionConfig{ + Model: "gpt-5", + InfiniteSessions: &copilot.InfiniteSessionConfig{ + Enabled: copilot.Bool(false), + }, +}) +``` + +When enabled, sessions emit compaction events: + +- `session.compaction_start` - Background compaction started +- `session.compaction_complete` - Compaction finished (includes token counts) + ## Transport Modes ### stdio (Default) diff --git a/go/client.go b/go/client.go index fe62d9f7..ada263de 100644 --- a/go/client.go +++ b/go/client.go @@ -544,6 +544,20 @@ func (c *Client) CreateSession(config *SessionConfig) (*Session, error) { if len(config.DisabledSkills) > 0 { params["disabledSkills"] = config.DisabledSkills } + // Add infinite sessions configuration + if config.InfiniteSessions != nil { + infiniteSessions := make(map[string]interface{}) + if config.InfiniteSessions.Enabled != nil { + infiniteSessions["enabled"] = *config.InfiniteSessions.Enabled + } + if config.InfiniteSessions.BackgroundCompactionThreshold != nil { + infiniteSessions["backgroundCompactionThreshold"] = *config.InfiniteSessions.BackgroundCompactionThreshold + } + if config.InfiniteSessions.BufferExhaustionThreshold != nil { + infiniteSessions["bufferExhaustionThreshold"] = *config.InfiniteSessions.BufferExhaustionThreshold + } + params["infiniteSessions"] = infiniteSessions + } } result, err := c.client.Request("session.create", params) @@ -556,7 +570,9 @@ func (c *Client) CreateSession(config *SessionConfig) (*Session, error) { return nil, fmt.Errorf("invalid response: missing sessionId") } - session := NewSession(sessionID, c.client) + workspacePath, _ := result["workspacePath"].(string) + + session := NewSession(sessionID, c.client, workspacePath) if config != nil { session.registerTools(config.Tools) @@ -692,7 +708,9 @@ func (c *Client) ResumeSessionWithOptions(sessionID string, config *ResumeSessio return nil, fmt.Errorf("invalid response: missing sessionId") } - session := NewSession(resumedSessionID, c.client) + workspacePath, _ := result["workspacePath"].(string) + + session := NewSession(resumedSessionID, c.client, workspacePath) if config != nil { session.registerTools(config.Tools) if config.OnPermissionRequest != nil { diff --git a/go/e2e/compaction_test.go b/go/e2e/compaction_test.go new file mode 100644 index 00000000..b054f15b --- /dev/null +++ b/go/e2e/compaction_test.go @@ -0,0 +1,122 @@ +package e2e + +import ( + "strings" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/e2e/testharness" +) + +func TestCompaction(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("should trigger compaction with low threshold and emit events", func(t *testing.T) { + ctx.ConfigureForTest(t) + + enabled := true + backgroundThreshold := 0.005 // 0.5% + bufferThreshold := 0.01 // 1% + + session, err := client.CreateSession(&copilot.SessionConfig{ + InfiniteSessions: &copilot.InfiniteSessionConfig{ + Enabled: &enabled, + BackgroundCompactionThreshold: &backgroundThreshold, + BufferExhaustionThreshold: &bufferThreshold, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + var compactionStartEvents []copilot.SessionEvent + var compactionCompleteEvents []copilot.SessionEvent + + session.On(func(event copilot.SessionEvent) { + if event.Type == copilot.SessionCompactionStart { + compactionStartEvents = append(compactionStartEvents, event) + } + if event.Type == copilot.SessionCompactionComplete { + compactionCompleteEvents = append(compactionCompleteEvents, event) + } + }) + + // Send multiple messages to fill up the context window + _, err = session.SendAndWait(copilot.MessageOptions{Prompt: "Tell me a long story about a dragon. Be very detailed."}, 60*time.Second) + if err != nil { + t.Fatalf("Failed to send first message: %v", err) + } + + _, err = session.SendAndWait(copilot.MessageOptions{Prompt: "Continue the story with more details about the dragon's castle."}, 60*time.Second) + if err != nil { + t.Fatalf("Failed to send second message: %v", err) + } + + _, err = session.SendAndWait(copilot.MessageOptions{Prompt: "Now describe the dragon's treasure in great detail."}, 60*time.Second) + if err != nil { + t.Fatalf("Failed to send third message: %v", err) + } + + // Should have triggered compaction at least once + if len(compactionStartEvents) < 1 { + t.Errorf("Expected at least 1 compaction_start event, got %d", len(compactionStartEvents)) + } + if len(compactionCompleteEvents) < 1 { + t.Errorf("Expected at least 1 compaction_complete event, got %d", len(compactionCompleteEvents)) + } + + // Compaction should have succeeded + if len(compactionCompleteEvents) > 0 { + lastComplete := compactionCompleteEvents[len(compactionCompleteEvents)-1] + if lastComplete.Data.Success == nil || !*lastComplete.Data.Success { + t.Errorf("Expected compaction to succeed") + } + if lastComplete.Data.TokensRemoved != nil && *lastComplete.Data.TokensRemoved <= 0 { + t.Errorf("Expected tokensRemoved > 0, got %v", *lastComplete.Data.TokensRemoved) + } + } + + // Verify session still works after compaction + answer, err := session.SendAndWait(copilot.MessageOptions{Prompt: "What was the story about?"}, 60*time.Second) + if err != nil { + t.Fatalf("Failed to send verification message: %v", err) + } + if answer.Data.Content == nil || !strings.Contains(strings.ToLower(*answer.Data.Content), "dragon") { + t.Errorf("Expected answer to contain 'dragon', got %v", answer.Data.Content) + } + }) + + t.Run("should not emit compaction events when infinite sessions disabled", func(t *testing.T) { + ctx.ConfigureForTest(t) + + enabled := false + session, err := client.CreateSession(&copilot.SessionConfig{ + InfiniteSessions: &copilot.InfiniteSessionConfig{ + Enabled: &enabled, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + var compactionEvents []copilot.SessionEvent + session.On(func(event copilot.SessionEvent) { + if event.Type == copilot.SessionCompactionStart || event.Type == copilot.SessionCompactionComplete { + compactionEvents = append(compactionEvents, event) + } + }) + + _, err = session.SendAndWait(copilot.MessageOptions{Prompt: "What is 2+2?"}, 60*time.Second) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // Should not have any compaction events when disabled + if len(compactionEvents) != 0 { + t.Errorf("Expected 0 compaction events when disabled, got %d", len(compactionEvents)) + } + }) +} diff --git a/go/session.go b/go/session.go index ddafb96e..d6b0b23a 100644 --- a/go/session.go +++ b/go/session.go @@ -47,6 +47,7 @@ type sessionHandler struct { type Session struct { // SessionID is the unique identifier for this session. SessionID string + workspacePath string client *JSONRPCClient handlers []sessionHandler nextHandlerID uint64 @@ -57,16 +58,24 @@ type Session struct { permissionMux sync.RWMutex } +// WorkspacePath returns the path to the session workspace directory when infinite +// sessions are enabled. Contains checkpoints/, plan.md, and files/ subdirectories. +// Returns empty string if infinite sessions are disabled. +func (s *Session) WorkspacePath() string { + return s.workspacePath +} + // NewSession creates a new session wrapper with the given session ID and client. // // Note: This function is primarily for internal use. Use [Client.CreateSession] // to create sessions with proper initialization. -func NewSession(sessionID string, client *JSONRPCClient) *Session { +func NewSession(sessionID string, client *JSONRPCClient, workspacePath string) *Session { return &Session{ - SessionID: sessionID, - client: client, - handlers: make([]sessionHandler, 0), - toolHandlers: make(map[string]ToolHandler), + SessionID: sessionID, + workspacePath: workspacePath, + client: client, + handlers: make([]sessionHandler, 0), + toolHandlers: make(map[string]ToolHandler), } } diff --git a/go/types.go b/go/types.go index 2fde2920..0bc8b6f7 100644 --- a/go/types.go +++ b/go/types.go @@ -43,6 +43,12 @@ func Bool(v bool) *bool { return &v } +// Float64 returns a pointer to the given float64 value. +// Use for setting thresholds: BackgroundCompactionThreshold: Float64(0.80) +func Float64(v float64) *float64 { + return &v +} + // SystemMessageAppendConfig is append mode: use CLI foundation with optional appended content. type SystemMessageAppendConfig struct { // Mode is optional, defaults to "append" @@ -132,6 +138,20 @@ type CustomAgentConfig struct { Infer *bool `json:"infer,omitempty"` } +// InfiniteSessionConfig configures infinite sessions with automatic context compaction +// and workspace persistence. When enabled, sessions automatically manage context window +// limits through background compaction and persist state to a workspace directory. +type InfiniteSessionConfig struct { + // Enabled controls whether infinite sessions are enabled (default: true) + Enabled *bool + // BackgroundCompactionThreshold is the context utilization (0.0-1.0) at which + // background compaction starts. Default: 0.80 + BackgroundCompactionThreshold *float64 + // BufferExhaustionThreshold is the context utilization (0.0-1.0) at which + // the session blocks until compaction completes. Default: 0.95 + BufferExhaustionThreshold *float64 +} + // SessionConfig configures a new session type SessionConfig struct { // SessionID is an optional custom session ID @@ -167,6 +187,9 @@ type SessionConfig struct { SkillDirectories []string // DisabledSkills is a list of skill names to disable DisabledSkills []string + // InfiniteSessions configures infinite sessions for persistent workspaces and automatic compaction. + // When enabled (default), sessions automatically manage context limits and persist state. + InfiniteSessions *InfiniteSessionConfig } // Tool describes a caller-implemented tool that can be invoked by Copilot diff --git a/nodejs/README.md b/nodejs/README.md index 97d51304..bd4ef15b 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -89,10 +89,11 @@ Create a new conversation session. - `model?: string` - Model to use ("gpt-5", "claude-sonnet-4.5", etc.) - `tools?: Tool[]` - Custom tools exposed to the CLI - `systemMessage?: SystemMessageConfig` - System message customization (see below) +- `infiniteSessions?: InfiniteSessionConfig` - Configure automatic context compaction (see below) ##### `resumeSession(sessionId: string, config?: ResumeSessionConfig): Promise` -Resume an existing session. +Resume an existing session. Returns the session with `workspacePath` populated if infinite sessions were enabled. ##### `ping(message?: string): Promise<{ message: string; timestamp: number }>` @@ -116,6 +117,16 @@ Delete a session and its data from disk. Represents a single conversation session. +#### Properties + +##### `sessionId: string` + +The unique identifier for this session. + +##### `workspacePath?: string` + +Path to the session workspace directory when infinite sessions are enabled. Contains `checkpoints/`, `plan.md`, and `files/` subdirectories. Undefined if infinite sessions are disabled. + #### Methods ##### `send(options: MessageOptions): Promise` @@ -327,6 +338,40 @@ const session = await client.createSession({ }); ``` +### Infinite Sessions + +By default, sessions use **infinite sessions** which automatically manage context window limits through background compaction and persist state to a workspace directory. + +```typescript +// Default: infinite sessions enabled with default thresholds +const session = await client.createSession({ model: "gpt-5" }); + +// Access the workspace path for checkpoints and files +console.log(session.workspacePath); +// => ~/.copilot/session-state/{sessionId}/ + +// Custom thresholds +const session = await client.createSession({ + model: "gpt-5", + infiniteSessions: { + enabled: true, + backgroundCompactionThreshold: 0.80, // Start compacting at 80% context usage + bufferExhaustionThreshold: 0.95, // Block at 95% until compaction completes + }, +}); + +// Disable infinite sessions +const session = await client.createSession({ + model: "gpt-5", + infiniteSessions: { enabled: false }, +}); +``` + +When enabled, sessions emit compaction events: + +- `session.compaction_start` - Background compaction started +- `session.compaction_complete` - Compaction finished (includes token counts) + ### Multiple Sessions ```typescript diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 588eee35..6c2bb94f 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.389", + "@github/copilot": "^0.0.394", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.5" }, @@ -662,26 +662,26 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.389", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.389.tgz", - "integrity": "sha512-XCHMCd8fu7g9WAp+ZepXBF1ud8vdfxDG4ajstGJqHfbdz0RxQktB35R5s/vKizpYXSZogFqwjxl41qX8DypY6g==", - "license": "MIT", + "version": "0.0.394", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.394.tgz", + "integrity": "sha512-koSiaHvVwjgppgh+puxf6dgsR8ql/WST1scS5bjzMsJFfWk7f4xtEXla7TCQfSGoZkCmCsr2Tis27v5TpssiCg==", + "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.389", - "@github/copilot-darwin-x64": "0.0.389", - "@github/copilot-linux-arm64": "0.0.389", - "@github/copilot-linux-x64": "0.0.389", - "@github/copilot-win32-arm64": "0.0.389", - "@github/copilot-win32-x64": "0.0.389" + "@github/copilot-darwin-arm64": "0.0.394", + "@github/copilot-darwin-x64": "0.0.394", + "@github/copilot-linux-arm64": "0.0.394", + "@github/copilot-linux-x64": "0.0.394", + "@github/copilot-win32-arm64": "0.0.394", + "@github/copilot-win32-x64": "0.0.394" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.389", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.389.tgz", - "integrity": "sha512-4Crm/C9//ZPsK+NP5E5BEjltAGuij9XkvRILvZ/mqlaiDXRncFvUtdOoV+/Of+i4Zva/1sWnc7CrS7PHGJDyFg==", + "version": "0.0.394", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.394.tgz", + "integrity": "sha512-qDmDFiFaYFW45UhxylN2JyQRLVGLCpkr5UmgbfH5e0aksf+69qytK/MwpD2Cq12KdTjyGMEorlADkSu5eftELA==", "cpu": [ "arm64" ], @@ -695,9 +695,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.389", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.389.tgz", - "integrity": "sha512-w0LB+lw29UmRS9oW8ENyZhrf3S5LQ3Pz796dQY8LZybp7WxEGtQhvXN48mye9gGzOHNoHxQ2+10+OzsjC/mLUQ==", + "version": "0.0.394", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.394.tgz", + "integrity": "sha512-iN4YwSVFxhASiBjLk46f+AzRTNHCvYcmyTKBASxieMIhnDxznYmpo+haFKPCv2lCsEWU8s5LARCnXxxx8J1wKA==", "cpu": [ "x64" ], @@ -711,9 +711,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.389", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.389.tgz", - "integrity": "sha512-8QNvfs4r6nrbQrT4llu0CbJHcCJosyj+ZgLSpA+lqIiO/TiTQ48kV41uNjzTz1RmR6/qBKcz81FB7HcHXpT3xw==", + "version": "0.0.394", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.394.tgz", + "integrity": "sha512-9NeGvmO2tGztuneXZfYAyW3fDk6Pdl6Ffg8MAUaevA/p0awvA+ti/Vh0ZSTcI81nDTjkzONvrcIcjYAN7x0oSg==", "cpu": [ "arm64" ], @@ -727,9 +727,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.389", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.389.tgz", - "integrity": "sha512-ls42wSzspC7sLiweoqu2zT75mqMsLWs+IZBfCqcuH1BV+C/j/XSEHsSrJxAI3TPtIsOTolPbTAa8jye1nGDxeg==", + "version": "0.0.394", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.394.tgz", + "integrity": "sha512-toahsYQORrP/TPSBQ7sxj4/fJg3YUrD0ksCj/Z4y2vT6EwrE9iC2BspKgQRa4CBoCqxYDNB2blc+mQ1UuzPOxg==", "cpu": [ "x64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.389", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.389.tgz", - "integrity": "sha512-loniaCnrty9okQMl3EhxeeyDhnrJ/lJK0Q0r7wkLf1d/TM2swp3tsGZyIRlhDKx5lgcnCPm1m0BqauMo8Vs34g==", + "version": "0.0.394", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.394.tgz", + "integrity": "sha512-R7XBP3l+oeDuBrP0KD80ZBEMsZoxAW8QO2MNsDUV8eVrNJnp6KtGHoA+iCsKYKNOD6wHA/q5qm/jR+gpsz46Aw==", "cpu": [ "arm64" ], @@ -759,9 +759,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.389", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.389.tgz", - "integrity": "sha512-L1ZzwV/vsxnrz0WO4qLDUlXXFQQ9fOFuBGKWy6TXS7aniaxI/7mdRQR1YjIEqy+AzRw9BaXR2UUUUDk0gb1+kw==", + "version": "0.0.394", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.394.tgz", + "integrity": "sha512-/XYV8srP+pMXbf9Gc3wr58zCzBZvsdA3X4poSvr2uU8yCZ6E4pD0agFaZ1c/CikANJi8nb0Id3kulhEhePz/3A==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 37cb1b1a..887bac91 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -40,7 +40,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.389", + "@github/copilot": "^0.0.394", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.5" }, diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 93e16d37..a698383a 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -452,10 +452,14 @@ export class CopilotClient { configDir: config.configDir, skillDirectories: config.skillDirectories, disabledSkills: config.disabledSkills, + infiniteSessions: config.infiniteSessions, }); - const sessionId = (response as { sessionId: string }).sessionId; - const session = new CopilotSession(sessionId, this.connection!); + const { sessionId, workspacePath } = response as { + sessionId: string; + workspacePath?: string; + }; + const session = new CopilotSession(sessionId, this.connection!, workspacePath); session.registerTools(config.tools); if (config.onPermissionRequest) { session.registerPermissionHandler(config.onPermissionRequest); @@ -516,8 +520,11 @@ export class CopilotClient { disabledSkills: config.disabledSkills, }); - const resumedSessionId = (response as { sessionId: string }).sessionId; - const session = new CopilotSession(resumedSessionId, this.connection!); + const { sessionId: resumedSessionId, workspacePath } = response as { + sessionId: string; + workspacePath?: string; + }; + const session = new CopilotSession(resumedSessionId, this.connection!, workspacePath); session.registerTools(config.tools); if (config.onPermissionRequest) { session.registerPermissionHandler(config.onPermissionRequest); diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index cfbd13b1..014a9b43 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -17,6 +17,7 @@ export type { CustomAgentConfig, GetAuthStatusResponse, GetStatusResponse, + InfiniteSessionConfig, MCPLocalServerConfig, MCPRemoteServerConfig, MCPServerConfig, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index ca9789c6..e285e7ca 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -57,13 +57,24 @@ export class CopilotSession { * * @param sessionId - The unique identifier for this session * @param connection - The JSON-RPC message connection to the Copilot CLI + * @param workspacePath - Path to the session workspace directory (when infinite sessions enabled) * @internal This constructor is internal. Use {@link CopilotClient.createSession} to create sessions. */ constructor( public readonly sessionId: string, - private connection: MessageConnection + private connection: MessageConnection, + private readonly _workspacePath?: string ) {} + /** + * Path to the session workspace directory when infinite sessions are enabled. + * Contains checkpoints/, plan.md, and files/ subdirectories. + * Undefined if infinite sessions are disabled. + */ + get workspacePath(): string | undefined { + return this._workspacePath; + } + /** * Sends a message to this session and waits for the response. * diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index bcc247a2..406fe8d5 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -312,6 +312,33 @@ export interface CustomAgentConfig { infer?: boolean; } +/** + * Configuration for infinite sessions with automatic context compaction and workspace persistence. + * When enabled, sessions automatically manage context window limits through background compaction + * and persist state to a workspace directory. + */ +export interface InfiniteSessionConfig { + /** + * Whether infinite sessions are enabled. + * @default true + */ + enabled?: boolean; + + /** + * Context utilization threshold (0.0-1.0) at which background compaction starts. + * Compaction runs asynchronously, allowing the session to continue processing. + * @default 0.80 + */ + backgroundCompactionThreshold?: number; + + /** + * Context utilization threshold (0.0-1.0) at which the session blocks until compaction completes. + * This prevents context overflow when compaction hasn't finished in time. + * @default 0.95 + */ + bufferExhaustionThreshold?: number; +} + export interface SessionConfig { /** * Optional custom session ID @@ -394,6 +421,13 @@ export interface SessionConfig { * List of skill names to disable. */ disabledSkills?: string[]; + + /** + * Infinite session configuration for persistent workspaces and automatic compaction. + * When enabled (default), sessions automatically manage context limits and persist state. + * Set to `{ enabled: false }` to disable. + */ + infiniteSessions?: InfiniteSessionConfig; } /** diff --git a/nodejs/test/e2e/compaction.test.ts b/nodejs/test/e2e/compaction.test.ts new file mode 100644 index 00000000..820b72ff --- /dev/null +++ b/nodejs/test/e2e/compaction.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { SessionEvent } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("Compaction", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should trigger compaction with low threshold and emit events", async () => { + // Create session with very low compaction thresholds to trigger compaction quickly + const session = await client.createSession({ + infiniteSessions: { + enabled: true, + // Trigger background compaction at 0.5% context usage (~1000 tokens) + backgroundCompactionThreshold: 0.005, + // Block at 1% to ensure compaction runs + bufferExhaustionThreshold: 0.01, + }, + }); + + const events: SessionEvent[] = []; + session.on((event) => { + events.push(event); + }); + + // Send multiple messages to fill up the context window + // With such low thresholds, even a few messages should trigger compaction + await session.sendAndWait({ + prompt: "Tell me a long story about a dragon. Be very detailed.", + }); + await session.sendAndWait({ + prompt: "Continue the story with more details about the dragon's castle.", + }); + await session.sendAndWait({ + prompt: "Now describe the dragon's treasure in great detail.", + }); + + // Check for compaction events + const compactionStartEvents = events.filter((e) => e.type === "session.compaction_start"); + const compactionCompleteEvents = events.filter( + (e) => e.type === "session.compaction_complete" + ); + + // Should have triggered compaction at least once + expect(compactionStartEvents.length).toBeGreaterThanOrEqual(1); + expect(compactionCompleteEvents.length).toBeGreaterThanOrEqual(1); + + // Compaction should have succeeded + const lastCompactionComplete = + compactionCompleteEvents[compactionCompleteEvents.length - 1]; + expect(lastCompactionComplete.data.success).toBe(true); + + // Should have removed some tokens + if (lastCompactionComplete.data.tokensRemoved !== undefined) { + expect(lastCompactionComplete.data.tokensRemoved).toBeGreaterThan(0); + } + + // Verify the session still works after compaction + const answer = await session.sendAndWait({ prompt: "What was the story about?" }); + expect(answer?.data.content).toBeDefined(); + // Should remember it was about a dragon (context preserved via summary) + expect(answer?.data.content?.toLowerCase()).toContain("dragon"); + }, 120000); + + it("should not emit compaction events when infinite sessions disabled", async () => { + const session = await client.createSession({ + infiniteSessions: { + enabled: false, + }, + }); + + const compactionEvents: SessionEvent[] = []; + session.on((event) => { + if ( + event.type === "session.compaction_start" || + event.type === "session.compaction_complete" + ) { + compactionEvents.push(event); + } + }); + + await session.sendAndWait({ prompt: "What is 2+2?" }); + + // Should not have any compaction events when disabled + expect(compactionEvents.length).toBe(0); + }); +}); diff --git a/python/README.md b/python/README.md index b8a436ea..fefc1e0f 100644 --- a/python/README.md +++ b/python/README.md @@ -239,6 +239,40 @@ When `streaming=True`: Note: `assistant.message` and `assistant.reasoning` (final events) are always sent regardless of streaming setting. +## Infinite Sessions + +By default, sessions use **infinite sessions** which automatically manage context window limits through background compaction and persist state to a workspace directory. + +```python +# Default: infinite sessions enabled with default thresholds +session = await client.create_session({"model": "gpt-5"}) + +# Access the workspace path for checkpoints and files +print(session.workspace_path) +# => ~/.copilot/session-state/{session_id}/ + +# Custom thresholds +session = await client.create_session({ + "model": "gpt-5", + "infinite_sessions": { + "enabled": True, + "background_compaction_threshold": 0.80, # Start compacting at 80% context usage + "buffer_exhaustion_threshold": 0.95, # Block at 95% until compaction completes + }, +}) + +# Disable infinite sessions +session = await client.create_session({ + "model": "gpt-5", + "infinite_sessions": {"enabled": False}, +}) +``` + +When enabled, sessions emit compaction events: + +- `session.compaction_start` - Background compaction started +- `session.compaction_complete` - Compaction finished (includes token counts) + ## Requirements - Python 3.9+ diff --git a/python/copilot/client.py b/python/copilot/client.py index 97496f1b..6870bda4 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -421,12 +421,29 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo if disabled_skills: payload["disabledSkills"] = disabled_skills + # Add infinite sessions configuration if provided + infinite_sessions = cfg.get("infinite_sessions") + if infinite_sessions: + wire_config: dict[str, Any] = {} + if "enabled" in infinite_sessions: + wire_config["enabled"] = infinite_sessions["enabled"] + if "background_compaction_threshold" in infinite_sessions: + wire_config["backgroundCompactionThreshold"] = infinite_sessions[ + "background_compaction_threshold" + ] + if "buffer_exhaustion_threshold" in infinite_sessions: + wire_config["bufferExhaustionThreshold"] = infinite_sessions[ + "buffer_exhaustion_threshold" + ] + payload["infiniteSessions"] = wire_config + if not self._client: raise RuntimeError("Client not connected") response = await self._client.request("session.create", payload) session_id = response["sessionId"] - session = CopilotSession(session_id, self._client) + workspace_path = response.get("workspacePath") + session = CopilotSession(session_id, self._client, workspace_path) session._register_tools(tools) if on_permission_request: session._register_permission_handler(on_permission_request) @@ -529,7 +546,8 @@ async def resume_session( response = await self._client.request("session.resume", payload) resumed_session_id = response["sessionId"] - session = CopilotSession(resumed_session_id, self._client) + workspace_path = response.get("workspacePath") + session = CopilotSession(resumed_session_id, self._client, workspace_path) session._register_tools(cfg.get("tools")) if on_permission_request: session._register_permission_handler(on_permission_request) diff --git a/python/copilot/session.py b/python/copilot/session.py index 63a1cb0d..996b5e9f 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -48,7 +48,7 @@ class CopilotSession: ... unsubscribe() """ - def __init__(self, session_id: str, client: Any): + def __init__(self, session_id: str, client: Any, workspace_path: Optional[str] = None): """ Initialize a new CopilotSession. @@ -59,9 +59,12 @@ def __init__(self, session_id: str, client: Any): Args: session_id: The unique identifier for this session. client: The internal client connection to the Copilot CLI. + workspace_path: Path to the session workspace directory + (when infinite sessions enabled). """ self.session_id = session_id self._client = client + self._workspace_path = workspace_path self._event_handlers: set[Callable[[SessionEvent], None]] = set() self._event_handlers_lock = threading.Lock() self._tool_handlers: dict[str, ToolHandler] = {} @@ -69,6 +72,16 @@ def __init__(self, session_id: str, client: Any): self._permission_handler: Optional[PermissionHandler] = None self._permission_handler_lock = threading.Lock() + @property + def workspace_path(self) -> Optional[str]: + """ + Path to the session workspace directory when infinite sessions are enabled. + + Contains checkpoints/, plan.md, and files/ subdirectories. + None if infinite sessions are disabled. + """ + return self._workspace_path + async def send(self, options: MessageOptions) -> str: """ Send a message to this session and wait for the response. diff --git a/python/copilot/types.py b/python/copilot/types.py index 2a06be98..bb64dd98 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -193,6 +193,27 @@ class CustomAgentConfig(TypedDict, total=False): infer: NotRequired[bool] # Whether agent is available for model inference +class InfiniteSessionConfig(TypedDict, total=False): + """ + Configuration for infinite sessions with automatic context compaction + and workspace persistence. + + When enabled, sessions automatically manage context window limits through + background compaction and persist state to a workspace directory. + """ + + # Whether infinite sessions are enabled (default: True) + enabled: bool + # Context utilization threshold (0.0-1.0) at which background compaction starts. + # Compaction runs asynchronously, allowing the session to continue processing. + # Default: 0.80 + background_compaction_threshold: float + # Context utilization threshold (0.0-1.0) at which the session blocks until + # compaction completes. This prevents context overflow when compaction hasn't + # finished in time. Default: 0.95 + buffer_exhaustion_threshold: float + + # Configuration for creating a session class SessionConfig(TypedDict, total=False): """Configuration for creating a session""" @@ -224,6 +245,10 @@ class SessionConfig(TypedDict, total=False): skill_directories: list[str] # List of skill names to disable disabled_skills: list[str] + # Infinite session configuration for persistent workspaces and automatic compaction. + # When enabled (default), sessions automatically manage context limits and persist state. + # Set to {"enabled": False} to disable. + infinite_sessions: InfiniteSessionConfig # Azure-specific provider options diff --git a/python/e2e/test_compaction.py b/python/e2e/test_compaction.py new file mode 100644 index 00000000..b2463e44 --- /dev/null +++ b/python/e2e/test_compaction.py @@ -0,0 +1,90 @@ +"""E2E Compaction Tests""" + +import pytest + +from copilot.generated.session_events import SessionEventType + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestCompaction: + @pytest.mark.timeout(120) + async def test_should_trigger_compaction_with_low_threshold_and_emit_events( + self, ctx: E2ETestContext + ): + # Create session with very low compaction thresholds to trigger compaction quickly + session = await ctx.client.create_session( + { + "infinite_sessions": { + "enabled": True, + # Trigger background compaction at 0.5% context usage (~1000 tokens) + "background_compaction_threshold": 0.005, + # Block at 1% to ensure compaction runs + "buffer_exhaustion_threshold": 0.01, + } + } + ) + + compaction_start_events = [] + compaction_complete_events = [] + + def on_event(event): + if event.type == SessionEventType.SESSION_COMPACTION_START: + compaction_start_events.append(event) + if event.type == SessionEventType.SESSION_COMPACTION_COMPLETE: + compaction_complete_events.append(event) + + session.on(on_event) + + # Send multiple messages to fill up the context window + await session.send_and_wait( + {"prompt": "Tell me a long story about a dragon. Be very detailed."} + ) + await session.send_and_wait( + {"prompt": "Continue the story with more details about the dragon's castle."} + ) + await session.send_and_wait( + {"prompt": "Now describe the dragon's treasure in great detail."} + ) + + # Should have triggered compaction at least once + assert len(compaction_start_events) >= 1, "Expected at least 1 compaction_start event" + assert len(compaction_complete_events) >= 1, "Expected at least 1 compaction_complete event" + + # Compaction should have succeeded + last_complete = compaction_complete_events[-1] + assert last_complete.data.success is True, "Expected compaction to succeed" + + # Should have removed some tokens + if last_complete.data.tokens_removed is not None: + assert last_complete.data.tokens_removed > 0, "Expected tokensRemoved > 0" + + # Verify the session still works after compaction + answer = await session.send_and_wait({"prompt": "What was the story about?"}) + assert answer is not None + assert answer.data.content is not None + # Should remember it was about a dragon (context preserved via summary) + assert "dragon" in answer.data.content.lower() + + async def test_should_not_emit_compaction_events_when_infinite_sessions_disabled( + self, ctx: E2ETestContext + ): + session = await ctx.client.create_session({"infinite_sessions": {"enabled": False}}) + + compaction_events = [] + + def on_event(event): + if event.type in ( + SessionEventType.SESSION_COMPACTION_START, + SessionEventType.SESSION_COMPACTION_COMPLETE, + ): + compaction_events.append(event) + + session.on(on_event) + + await session.send_and_wait({"prompt": "What is 2+2?"}) + + # Should not have any compaction events when disabled + assert len(compaction_events) == 0, "Expected no compaction events when disabled" diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index 82814f32..e5624654 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^0.0.383", + "@github/copilot": "^0.0.394", "@types/node": "^25.0.3", "openai": "^6.15.0", "tsx": "^4.21.0", @@ -461,30 +461,27 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.383", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.383.tgz", - "integrity": "sha512-bE81nL/1YTppMS6gB/Nq7S+5EcD45awvrYgSkhLZKBuWhwOQ42jDp0g2lID1nR4GrwatV+FoDckQw2NpDPY93A==", + "version": "0.0.394", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.394.tgz", + "integrity": "sha512-koSiaHvVwjgppgh+puxf6dgsR8ql/WST1scS5bjzMsJFfWk7f4xtEXla7TCQfSGoZkCmCsr2Tis27v5TpssiCg==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, - "engines": { - "node": ">=22" - }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.383", - "@github/copilot-darwin-x64": "0.0.383", - "@github/copilot-linux-arm64": "0.0.383", - "@github/copilot-linux-x64": "0.0.383", - "@github/copilot-win32-arm64": "0.0.383", - "@github/copilot-win32-x64": "0.0.383" + "@github/copilot-darwin-arm64": "0.0.394", + "@github/copilot-darwin-x64": "0.0.394", + "@github/copilot-linux-arm64": "0.0.394", + "@github/copilot-linux-x64": "0.0.394", + "@github/copilot-win32-arm64": "0.0.394", + "@github/copilot-win32-x64": "0.0.394" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.383", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.383.tgz", - "integrity": "sha512-GfwHGgVmlYS3ksQhyBRQRUQtGtumRDoszByBfkyoJrDH9bLjAMM3EyS6r5nhmH7PMadjU4ZCkj8FGek7imDGtw==", + "version": "0.0.394", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.394.tgz", + "integrity": "sha512-qDmDFiFaYFW45UhxylN2JyQRLVGLCpkr5UmgbfH5e0aksf+69qytK/MwpD2Cq12KdTjyGMEorlADkSu5eftELA==", "cpu": [ "arm64" ], @@ -499,9 +496,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.383", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.383.tgz", - "integrity": "sha512-4gTjY9St/MyFadPpdvVYiGjvHPPYmFns6ic3AX3q+HTpj1zqGpnjLbwfZeM/Lfb84oMIhM2sR1G/Bv8B+T3l/g==", + "version": "0.0.394", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.394.tgz", + "integrity": "sha512-iN4YwSVFxhASiBjLk46f+AzRTNHCvYcmyTKBASxieMIhnDxznYmpo+haFKPCv2lCsEWU8s5LARCnXxxx8J1wKA==", "cpu": [ "x64" ], @@ -516,9 +513,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.383", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.383.tgz", - "integrity": "sha512-QoqK76G7sAh7DVpg2GlnIDa5lYe9FK9U1oFwOVjwXwwKJe8PpIWwNVeO4nERGrkc4CQy7u4U59GSmfXQzoFXvw==", + "version": "0.0.394", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.394.tgz", + "integrity": "sha512-9NeGvmO2tGztuneXZfYAyW3fDk6Pdl6Ffg8MAUaevA/p0awvA+ti/Vh0ZSTcI81nDTjkzONvrcIcjYAN7x0oSg==", "cpu": [ "arm64" ], @@ -533,9 +530,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.383", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.383.tgz", - "integrity": "sha512-EJHnq575pv7N586WjQkZZdDLqfd2GemGxk3aIhWrHtXMmLY4qRAJJBUnF1MtNqccTKuPmLuD8nAUTrxQp7sWPA==", + "version": "0.0.394", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.394.tgz", + "integrity": "sha512-toahsYQORrP/TPSBQ7sxj4/fJg3YUrD0ksCj/Z4y2vT6EwrE9iC2BspKgQRa4CBoCqxYDNB2blc+mQ1UuzPOxg==", "cpu": [ "x64" ], @@ -550,9 +547,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.383", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.383.tgz", - "integrity": "sha512-76NT8ULHpbmM/YOz71FPAUUfAhfEVqhEew+Wkqtgn+eG48gCnDYu3ZQIRbnWIh/oj6nYVTyi0wg9LUt7M8sFRQ==", + "version": "0.0.394", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.394.tgz", + "integrity": "sha512-R7XBP3l+oeDuBrP0KD80ZBEMsZoxAW8QO2MNsDUV8eVrNJnp6KtGHoA+iCsKYKNOD6wHA/q5qm/jR+gpsz46Aw==", "cpu": [ "arm64" ], @@ -567,9 +564,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.383", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.383.tgz", - "integrity": "sha512-/5r5uK8pUoefS8H9cax96GqBzm62uBeXEphct7SxPU/gnf2udDvb+0iBOlvKskAwdWNXLp3Khxgm4nfFgxrr9A==", + "version": "0.0.394", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.394.tgz", + "integrity": "sha512-/XYV8srP+pMXbf9Gc3wr58zCzBZvsdA3X4poSvr2uU8yCZ6E4pD0agFaZ1c/CikANJi8nb0Id3kulhEhePz/3A==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index cacceb4f..55de10f5 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -11,7 +11,7 @@ "test": "vitest run" }, "devDependencies": { - "@github/copilot": "^0.0.383", + "@github/copilot": "^0.0.394", "@types/node": "^25.0.3", "openai": "^6.15.0", "tsx": "^4.21.0", diff --git a/test/snapshots/compaction/should_not_emit_compaction_events_when_infinite_sessions_disabled.yaml b/test/snapshots/compaction/should_not_emit_compaction_events_when_infinite_sessions_disabled.yaml new file mode 100644 index 00000000..9773a132 --- /dev/null +++ b/test/snapshots/compaction/should_not_emit_compaction_events_when_infinite_sessions_disabled.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 2+2? + - role: assistant + content: 2+2 equals 4. diff --git a/test/snapshots/compaction/should_trigger_compaction_with_low_threshold_and_emit_events.yaml b/test/snapshots/compaction/should_trigger_compaction_with_low_threshold_and_emit_events.yaml new file mode 100644 index 00000000..a33904cf --- /dev/null +++ b/test/snapshots/compaction/should_trigger_compaction_with_low_threshold_and_emit_events.yaml @@ -0,0 +1,518 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Tell me a long story about a dragon. Be very detailed. + - role: assistant + content: >- + I'm a terminal assistant designed to help with software engineering tasks like writing code, debugging, and + managing repositories. While I can tell you a story, it's a bit outside my main purpose! + + + Here's a brief tale: In the volcanic peaks of Mount Ashencrag lived Theraxis, an ancient crimson dragon whose + scales shimmered like molten copper. For centuries, she guarded a library of stone tablets inscribed by the + first civilizations, breathing gentle warmth to preserve them through harsh winters. When a young scholar + stumbled into her lair seeking forbidden knowledge, Theraxis didn't devour him—instead, she challenged him to + a game of riddles, each answer unlocking one tablet's secrets, teaching him that true wisdom required patience + and respect for those who came before. + + + Is there a coding project or technical task I can help you with instead? + - role: user + content: Continue the story with more details about the dragon's castle. + - role: user + content: >- + Please create a detailed summary of the conversation so far. The history is being compacted so moving forward, + all conversation history will be removed and you'll only have this summary to work from. Be sure to make note + of the user's explicit requests, your actions, and any key technical details. + + + The summary should include the following parts: + + 1. - high-level summary of goals and approach + + 2. - chronological analysis of the conversation + + 3. - changes made, current state, and any issues encountered + + 4. - key concepts, decisions, and quirks discovered + + 5. - files central to the work and why they matter + + 6. - pending tasks and planned actions + + 7. - 2-6 word description of the main work done + + + --- + + + ## Section Guidelines + + + ### Overview + + + Provide a concise summary (2-3 sentences) capturing the user's goals, intent, and expectations. Describe your + overall approach and strategy for addressing their needs, and note any constraints or requirements that were + established. + + This section should give a reader immediate clarity on what this conversation is about and how you're tackling + it. + + + ### History + + + Capture the narrative arc of the conversation—what was asked for, what was done, and how the work evolved. + Structure this around the user's requests: each request becomes an entry with the actions you took nested + underneath, in chronological order. + + Note any major pivots or changes in direction, and include outcomes where relevant—especially for debugging or + when something didn't go as expected. Focus on meaningful actions, not granular details of every exchange. + + + ### Work Done + + + Document the concrete work completed during this conversation. This section should enable someone to pick up + exactly where you left off. Include: + + + - Files created, modified, or deleted + + - Tasks completed and their outcomes + + - What you were most recently working on + + - Current state: what works, what doesn't, what's untested + + + ### Technical Details + + + Capture the technical knowledge that would be painful to rediscover. Think of this as a knowledge base for + your future self—anything that took effort to learn belongs here. This includes: + + + - Key concepts and architectural decisions (with rationale) + + - Issues encountered and how they were resolved + + - Quirks, gotchas, or non-obvious behaviors + + - Dependencies, versions, or environment details that matter + + - Workarounds or constraints you discovered + + + Also make note of any questions that remain unanswered or assumptions that you aren't fully confident about. + + + ### Important Files + + + List the files most central to the task, prioritizing those you've actively worked on over files you merely + viewed. This isn't an exhaustive inventory—it's a curated list of what matters most for continuing the work. + For each file, include: + + + - The file name + + - Why it's important to the project + + - Summary of changes made (if any) + + - Key line numbers or sections to reference + + + ### Next Steps + + + If there's pending work, describe what you were actively working on when compaction occurred. List remaining + tasks, outline your planned approach, and flag any blockers or open questions. + + If you've finished all requested work, you can simply note that no next steps are needed. + + + ### Checkpoint Title + + + Provide a concise 2-6 word title capturing the essence of what was accomplished in this work segment. This + title will be used to identify this checkpoint when reviewing session history. Examples: + + - "Implementing user authentication" + + - "Fixing database connection bugs" + + - "Refactoring payment module" + + - "Adding unit tests for API" + + + --- + + + ## Example + + + Here is an example of the structure you should follow: + + + + + + [2-3 sentences describing the user's goals and your approach] + + + + + + 1. The user asked to [request] + - [action taken] + - [action taken] + - [outcome/result] + + 2. The user asked to [request] + - [action taken] + - [action taken] + - [outcome/result] + + + + + Files updated: + + - [file]: [what changed] + + + Work completed: + + - [x] [Task] + + - [x] [Task] + + - [ ] [Task in progress or incomplete] + + + + + + - [Key technical concept or decision] + + - [Issue encountered and how it was resolved] + + - [Non-obvious behavior or quirk discovered] + + - [Unresolved question or uncertain area] + + + + + + - [file1] + - [Why it matters] + - [Changes made, if any] + - [Key line numbers] + - [file2] + - [Why it matters] + - [Changes made, if any] + - [Key line numbers] + + + + + Remaining work: + + - [Task] + + - [Task] + + + Immediate next steps: + + - [Action to take] + + - [Action to take] + + + + + Concise 2-6 word description of this checkpoint + + + + + --- + + + Please write the summary now, following the structure and guidelines above. Be concise where possible, but + don't sacrifice important context for brevity. + - role: assistant + content: >- + + + The user has made non-technical requests asking for creative storytelling content (a detailed dragon story and + details about the dragon's castle). No software engineering work has been requested or performed. As a + terminal assistant focused on coding and development tasks, I provided a brief story but redirected toward + technical assistance. + + + + + + + 1. The user asked for a long, detailed story about a dragon + - I explained my purpose as a software engineering assistant + - Provided a brief story about Theraxis, a dragon guarding ancient tablets + - Redirected to offer technical/coding assistance + + 2. The user asked to continue the story with more details about the dragon's castle, then requested a + conversation summary for history compaction + - Creating this summary document + + + + + + No technical work has been performed. No files have been created, modified, or viewed. No code has been + written. No repositories have been explored or modified. + + + Current state: The conversation has consisted entirely of non-technical storytelling requests with no software + engineering tasks initiated. + + + + + + + No technical details to report. No coding tasks, debugging, repository work, or software engineering + activities have occurred. + + + + + + + No files are relevant to this conversation. No files have been accessed, created, or modified. + + + + + + + No technical work is pending. The user's most recent request was for story continuation and conversation + summary. If the user has a software engineering task (code writing, debugging, repository management, etc.), + that would be the appropriate next direction. + + + + + Non-technical storytelling requests + - messages: + - role: system + content: ${system} + - role: user + content: Tell me a long story about a dragon. Be very detailed. + - role: assistant + content: >- + I'm a terminal assistant designed to help with software engineering tasks like writing code, debugging, and + managing repositories. While I can tell you a story, it's a bit outside my main purpose! + + + Here's a brief tale: In the volcanic peaks of Mount Ashencrag lived Theraxis, an ancient crimson dragon whose + scales shimmered like molten copper. For centuries, she guarded a library of stone tablets inscribed by the + first civilizations, breathing gentle warmth to preserve them through harsh winters. When a young scholar + stumbled into her lair seeking forbidden knowledge, Theraxis didn't devour him—instead, she challenged him to + a game of riddles, each answer unlocking one tablet's secrets, teaching him that true wisdom required patience + and respect for those who came before. + + + Is there a coding project or technical task I can help you with instead? + - role: user + content: Continue the story with more details about the dragon's castle. + - role: assistant + content: >- + I appreciate your interest in the story, but I'm really designed to help with software engineering tasks—like + writing code, fixing bugs, running tests, or managing files and repositories. + + + If you'd like, I could help you with something like: + + - Creating a text-based dragon adventure game + + - Building a story generator script + + - Setting up a creative writing project structure + + - Analyzing text files or documents + + + Would any of those technical tasks interest you? + - messages: + - role: system + content: ${system} + - role: user + content: >- + Some of the conversation history has been summarized to free up context. + + + You were originally given instructions from a user over one or more turns. Here were the user messages: + + + + Tell me a long story about a dragon. Be very detailed. + + + + + + Continue the story with more details about the dragon's castle. + + + + + Here is a summary of the prior context: + + + + + + The user has made non-technical requests asking for creative storytelling content (a detailed dragon story and + details about the dragon's castle). No software engineering work has been requested or performed. As a + terminal assistant focused on coding and development tasks, I provided a brief story but redirected toward + technical assistance. + + + + + + + 1. The user asked for a long, detailed story about a dragon + - I explained my purpose as a software engineering assistant + - Provided a brief story about Theraxis, a dragon guarding ancient tablets + - Redirected to offer technical/coding assistance + + 2. The user asked to continue the story with more details about the dragon's castle, then requested a + conversation summary for history compaction + - Creating this summary document + + + + + + No technical work has been performed. No files have been created, modified, or viewed. No code has been + written. No repositories have been explored or modified. + + + Current state: The conversation has consisted entirely of non-technical storytelling requests with no software + engineering tasks initiated. + + + + + + + No technical details to report. No coding tasks, debugging, repository work, or software engineering + activities have occurred. + + + + + + + No files are relevant to this conversation. No files have been accessed, created, or modified. + + + + + + + No technical work is pending. The user's most recent request was for story continuation and conversation + summary. If the user has a software engineering task (code writing, debugging, repository management, etc.), + that would be the appropriate next direction. + + + + + Non-technical storytelling requests + + + - role: assistant + content: >- + I appreciate your interest in the story, but I'm really designed to help with software engineering tasks—like + writing code, fixing bugs, running tests, or managing files and repositories. + + + If you'd like, I could help you with something like: + + - Creating a text-based dragon adventure game + + - Building a story generator script + + - Setting up a creative writing project structure + + - Analyzing text files or documents + + + Would any of those technical tasks interest you? + - role: user + content: Now describe the dragon's treasure in great detail. + - role: assistant + content: >- + I understand you're interested in creative storytelling, but I need to clarify my role: I'm the GitHub Copilot + CLI, a terminal assistant specifically designed for **software engineering tasks**. + + + I'm built to help you with: + + - Writing and debugging code + + - Managing git repositories + + - Running tests and builds + + - Creating and editing files + + - Installing dependencies + + - And other development tasks + + + I'm **not** designed for creative writing, storytelling, or generating narrative content. + + + If you have a **technical project** in mind, I'd be happy to help! For example: + + - Build a Python script that generates random dragon treasure descriptions + + - Create a JSON data structure for fantasy RPG items + + - Develop a text adventure game with dragons and treasure + + - Set up a web app for a dragon lore wiki + + + Do you have any coding or development tasks you'd like help with? + - role: user + content: What was the story about? + - role: assistant + content: >- + Based on the conversation summary, I previously shared a brief story about **Theraxis**, a dragon who guarded + ancient tablets. + + + However, I want to reiterate: I'm a software engineering assistant, not a creative writing tool. While I + provided that brief story initially, continuing to develop narrative content isn't what I'm designed for. + + + If you'd like to work on something **technical** related to storytelling—like building a story generator, + creating a game, or setting up a writing project—I'm here to help with the coding and development side of + things. + + + Is there a software engineering task I can assist you with?