Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/features/session-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ When resuming a session, you can optionally reconfigure many settings. This is u
| `availableTools` | Restrict which tools are available |
| `excludedTools` | Disable specific tools |
| `provider` | Re-provide BYOK credentials (required for BYOK sessions) |
| `capi.autoTier` | Override the persisted Auto routing preference on cold resume only |
| `reasoningEffort` | Adjust reasoning effort level |
| `streaming` | Enable/disable streaming responses |
| `workingDirectory` | Change the working directory |
Expand All @@ -253,6 +254,21 @@ When resuming a session, you can optionally reconfigure many settings. This is u
| `disabledSkills` | Skills to disable |
| `infiniteSessions` | Configure infinite session behavior |

### Auto tier persistence

With `model: "auto"`, the optional `capi.autoTier` setting selects an Auto routing preference: `efficiency`, `balance`, or `intelligence`. In Python, use `capi={"auto_tier": "balance"}`. This requires Copilot CLI `1.0.82-1` or later with V2 Auto routing; V1 Auto requests are unchanged.

The runtime persists the selected tier, so applications do not need to resend it on every resume:

* Omitting the tier when creating a session uses the runtime's default routing behavior.
* A cold resume restores the persisted tier. Supplying an explicit tier overrides the restored value for the new activation.
* When resuming a session already resident in the runtime, omitting the tier preserves the current selection, supplying the same tier is a no-op, and supplying a different tier is rejected.
* Older sessions without a persisted tier retain default routing behavior.

Tier selection is not a live model-switch operation. The SDK forwards the preference; the runtime owns persistence and validation.

The `session.start` and `session.resume` events expose the selected tier in their optional `data.autoTier` field (`data.auto_tier` in Python). When no tier is selected, the field is omitted.

### Example: changing model on resume

```typescript
Expand Down
12 changes: 12 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,18 @@ public sealed class CapiSessionOptions
/// </remarks>
[JsonPropertyName("enableWebSocketResponses")]
public bool? EnableWebSocketResponses { get; set; }

/// <summary>
/// Routing tier for model <c>auto</c> with V2 Auto.
/// </summary>
/// <remarks>
/// Requires a runtime that supports Auto tiers; it has no effect outside V2 Auto.
/// When omitted, the runtime uses its default on create and preserves the persisted or current
/// tier on resume. An explicit tier overrides the persisted tier on a cold resume; a conflicting
/// tier on a resident session resume is rejected by the runtime.
/// </remarks>
[JsonPropertyName("autoTier")]
public AutoTier? AutoTier { get; set; }
}

/// <summary>
Expand Down
88 changes: 88 additions & 0 deletions dotnet/test/Unit/ClientSessionLifetimeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,94 @@ public async Task CreateSessionAsync_Omits_CustomAgent_ReasoningEffort_When_Unse
Assert.False(agent.TryGetProperty("reasoningEffort", out _));
}

public static TheoryData<AutoTier, string, bool?> CapiAutoTiers => new()
{
{ AutoTier.Efficiency, "efficiency", null },
{ AutoTier.Balance, "balance", null },
{ AutoTier.Intelligence, "intelligence", null },
{ AutoTier.Efficiency, "efficiency", false },
{ AutoTier.Balance, "balance", false },
{ AutoTier.Intelligence, "intelligence", false },
};

[Theory]
[MemberData(nameof(CapiAutoTiers))]
public async Task SessionRequests_Serialize_CapiAutoTier(AutoTier tier, string expectedTier, bool? enableWebSocketResponses)
{
await using var server = await FakeCopilotServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
var capi = new CapiSessionOptions { AutoTier = tier, EnableWebSocketResponses = enableWebSocketResponses };

await using var created = await client.CreateSessionAsync(new SessionConfig
{
Model = "auto",
Capi = capi,
OnPermissionRequest = PermissionHandler.ApproveAll
});
await using var resumed = await client.ResumeSessionAsync("resume-with-auto-tier", new ResumeSessionConfig
{
Model = "auto",
Capi = capi,
OnPermissionRequest = PermissionHandler.ApproveAll
});

foreach (var method in new[] { "session.create", "session.resume" })
{
var request = Assert.Single(server.Requests, request => request.Method == method);
var serializedCapi = request.Params.GetProperty("capi");
Assert.Equal(expectedTier, serializedCapi.GetProperty("autoTier").GetString());
if (enableWebSocketResponses.HasValue)
{
Assert.Equal(enableWebSocketResponses.Value, serializedCapi.GetProperty("enableWebSocketResponses").GetBoolean());
}
else
{
Assert.False(serializedCapi.TryGetProperty("enableWebSocketResponses", out _));
}
}
}

[Theory]
[InlineData(false, null)]
[InlineData(true, null)]
[InlineData(true, false)]
public async Task SessionRequests_Omit_CapiAutoTier_WhenUnset(bool includeCapi, bool? enableWebSocketResponses)
{
await using var server = await FakeCopilotServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
var capi = includeCapi ? new CapiSessionOptions { EnableWebSocketResponses = enableWebSocketResponses } : null;

await using var created = await client.CreateSessionAsync(new SessionConfig
{
Model = "auto",
Capi = capi,
OnPermissionRequest = PermissionHandler.ApproveAll
});
await using var resumed = await client.ResumeSessionAsync("resume-without-auto-tier", new ResumeSessionConfig
{
Capi = capi,
OnPermissionRequest = PermissionHandler.ApproveAll
});

foreach (var method in new[] { "session.create", "session.resume" })
{
var request = Assert.Single(server.Requests, request => request.Method == method);
Assert.Equal(includeCapi, request.Params.TryGetProperty("capi", out var serializedCapi));
if (includeCapi)
{
Assert.False(serializedCapi.TryGetProperty("autoTier", out _));
if (enableWebSocketResponses.HasValue)
{
Assert.Equal(enableWebSocketResponses.Value, serializedCapi.GetProperty("enableWebSocketResponses").GetBoolean());
}
else
{
Assert.Empty(serializedCapi.EnumerateObject());
}
}
}
}

[Fact]
public async Task CreateSessionAsync_Forwards_AskUserVariant()
{
Expand Down
39 changes: 39 additions & 0 deletions dotnet/test/Unit/SessionEventSerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,45 @@ namespace GitHub.Copilot.Test.Unit;

public class SessionEventSerializationTests
{
public static TheoryData<AutoTier?, string?> AutoTiers => new()
{
{ AutoTier.Efficiency, "efficiency" },
{ AutoTier.Balance, "balance" },
{ AutoTier.Intelligence, "intelligence" },
{ null, null },
};

[Theory]
[MemberData(nameof(AutoTiers))]
public void SessionEvent_Deserializes_AutoTier(AutoTier? expectedTier, string? wireTier)
{
foreach (var eventType in new[] { "session.start", "session.resume" })
{
var autoTierProperty = wireTier is null ? "" : $""", "autoTier": "{wireTier}" """;
var json = $$"""
{
"id": "11111111-1111-1111-1111-111111111111",
"timestamp": "2026-08-28T00:00:00Z",
"parentId": null,
"type": "{{eventType}}",
"data": {
"sessionId": "test-session", "version": 1,
"producer": "copilot", "copilotVersion": "1.0.82-1",
"startTime": "2026-08-28T00:00:00Z",
"resumeTime": "2026-08-28T00:00:00Z", "eventCount": 1
{{autoTierProperty}}
}
}
""";

var sessionEvent = SessionEvent.FromJson(json);
var actualTier = eventType == "session.start"
? Assert.IsType<SessionStartEvent>(sessionEvent).Data.AutoTier
: Assert.IsType<SessionResumeEvent>(sessionEvent).Data.AutoTier;
Assert.Equal(expectedTier, actualTier);
}
}

public static TheoryData<SessionEvent, string> JsonElementBackedEvents => new()
{
{
Expand Down
95 changes: 61 additions & 34 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,42 +436,63 @@ func newRuntimeShutdownRpcPair(t *testing.T) (*jsonrpc2.Client, *jsonrpc2.Client
}

func TestClient_ForwardsCapiOptionsToSessionRequests(t *testing.T) {
rpcClient, server, _ := newRuntimeShutdownRpcPair(t)
t.Cleanup(server.Stop)
client := &Client{
client: rpcClient,
RPC: rpc.NewServerRPC(rpcClient),
sessions: make(map[string]*Session),
}
tests := []struct {
name string
capi *CapiSessionOptions
want map[string]any
}{
{"omitted", nil, nil},
{"empty", &CapiSessionOptions{}, map[string]any{}},
{"websocket only", &CapiSessionOptions{EnableWebSocketResponses: Bool(false)}, map[string]any{"enableWebSocketResponses": false}},
{"efficiency", &CapiSessionOptions{AutoTier: AutoTierEfficiency}, map[string]any{"autoTier": "efficiency"}},
{"balance", &CapiSessionOptions{AutoTier: AutoTierBalance}, map[string]any{"autoTier": "balance"}},
{"intelligence", &CapiSessionOptions{AutoTier: AutoTierIntelligence}, map[string]any{"autoTier": "intelligence"}},
{"efficiency with websocket", &CapiSessionOptions{AutoTier: AutoTierEfficiency, EnableWebSocketResponses: Bool(false)}, map[string]any{"autoTier": "efficiency", "enableWebSocketResponses": false}},
{"balance with websocket", &CapiSessionOptions{AutoTier: AutoTierBalance, EnableWebSocketResponses: Bool(false)}, map[string]any{"autoTier": "balance", "enableWebSocketResponses": false}},
{"intelligence with websocket", &CapiSessionOptions{AutoTier: AutoTierIntelligence, EnableWebSocketResponses: Bool(false)}, map[string]any{"autoTier": "intelligence", "enableWebSocketResponses": false}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rpcClient, server, _ := newRuntimeShutdownRpcPair(t)
t.Cleanup(server.Stop)
client := &Client{
client: rpcClient,
RPC: rpc.NewServerRPC(rpcClient),
sessions: make(map[string]*Session),
}

createParams := make(chan json.RawMessage, 1)
server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
createParams <- append(json.RawMessage(nil), params...)
sessionID := sessionIDFromParams(t, params)
return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil
})
createParams := make(chan json.RawMessage, 1)
server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
createParams <- append(json.RawMessage(nil), params...)
sessionID := sessionIDFromParams(t, params)
return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil
})

_, err := client.CreateSession(t.Context(), &SessionConfig{
Capi: &CapiSessionOptions{EnableWebSocketResponses: Bool(false)},
})
if err != nil {
t.Fatalf("CreateSession failed: %v", err)
}
assertCapiEnableWebSocketResponses(t, <-createParams)
_, err := client.CreateSession(t.Context(), &SessionConfig{
Model: "auto",
Capi: tt.capi,
})
if err != nil {
t.Fatalf("CreateSession failed: %v", err)
}
assertCapiOptions(t, <-createParams, tt.want)

resumeParams := make(chan json.RawMessage, 1)
server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
resumeParams <- append(json.RawMessage(nil), params...)
return []byte(`{"sessionId":"resumed-capi","workspacePath":"/workspace"}`), nil
})
resumeParams := make(chan json.RawMessage, 1)
server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
resumeParams <- append(json.RawMessage(nil), params...)
return []byte(`{"sessionId":"resumed-capi","workspacePath":"/workspace"}`), nil
})

_, err = client.ResumeSessionWithOptions(t.Context(), "resumed-capi", &ResumeSessionConfig{
Capi: &CapiSessionOptions{EnableWebSocketResponses: Bool(false)},
})
if err != nil {
t.Fatalf("ResumeSessionWithOptions failed: %v", err)
_, err = client.ResumeSessionWithOptions(t.Context(), "resumed-capi", &ResumeSessionConfig{
Model: "auto",
Capi: tt.capi,
})
if err != nil {
t.Fatalf("ResumeSessionWithOptions failed: %v", err)
}
assertCapiOptions(t, <-resumeParams, tt.want)
})
}
assertCapiEnableWebSocketResponses(t, <-resumeParams)
}

func TestClient_ForwardsAskUserVariantToSessionRequests(t *testing.T) {
Expand Down Expand Up @@ -728,20 +749,26 @@ func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) {
assertNewSessionOptions(t, <-resumeParams, false, false, "task", 15)
}

func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) {
func assertCapiOptions(t *testing.T, params json.RawMessage, want map[string]any) {
t.Helper()

var decoded map[string]any
if err := json.Unmarshal(params, &decoded); err != nil {
t.Fatalf("failed to unmarshal request params: %v", err)
}

if want == nil {
if _, present := decoded["capi"]; present {
t.Fatalf("expected capi to be omitted, got %v", decoded["capi"])
}
return
}
capi, ok := decoded["capi"].(map[string]any)
if !ok {
t.Fatalf("expected capi object in request params, got %T", decoded["capi"])
}
if capi["enableWebSocketResponses"] != false {
t.Fatalf("expected capi.enableWebSocketResponses=false, got %v", capi["enableWebSocketResponses"])
if !reflect.DeepEqual(capi, want) {
t.Fatalf("expected capi %v, got %v", want, capi)
}
}

Expand Down
44 changes: 44 additions & 0 deletions go/session_event_serialization_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,50 @@ var _ SessionEventData = (*rpc.UserMessageData)(nil)
var _ rpc.EmbeddedTextResourceContents = EmbeddedTextResourceContents{}
var _ EmbeddedTextResourceContents = rpc.EmbeddedTextResourceContents{}

func TestSessionEventAutoTier(t *testing.T) {
for _, eventType := range []string{"session.start", "session.resume"} {
for _, tier := range []AutoTier{"", AutoTierEfficiency, AutoTierBalance, AutoTierIntelligence} {
t.Run(eventType+"/"+string(tier), func(t *testing.T) {
data := map[string]any{
"sessionId": "test-session", "version": 1,
"producer": "copilot", "copilotVersion": "1.0.82-1",
"startTime": "2026-08-28T00:00:00Z",
"resumeTime": "2026-08-28T00:00:00Z", "eventCount": 1,
}
if tier != "" {
data["autoTier"] = tier
}
wire, err := json.Marshal(map[string]any{
"id": "00000000-0000-0000-0000-000000000001",
"timestamp": "2026-08-28T00:00:00Z", "parentId": nil,
"type": eventType, "data": data,
})
if err != nil {
t.Fatal(err)
}
var event SessionEvent
if err := json.Unmarshal(wire, &event); err != nil {
t.Fatal(err)
}
var actual *AutoTier
switch eventType {
case "session.start":
actual = event.Data.(*SessionStartData).AutoTier
case "session.resume":
actual = event.Data.(*SessionResumeData).AutoTier
}
if tier == "" {
if actual != nil {
t.Fatalf("expected omitted autoTier, got %v", *actual)
}
} else if actual == nil || *actual != tier {
t.Fatalf("expected autoTier %q, got %v", tier, actual)
}
})
}
}
}

func TestSessionEventAgentIDRoundTripsKnownEvent(t *testing.T) {
var event SessionEvent
if err := json.Unmarshal([]byte(`{
Expand Down
20 changes: 20 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2225,6 +2225,18 @@ func (p ProviderConfig) MarshalJSON() ([]byte, error) {
return json.Marshal(aux)
}

// AutoTier selects the routing tier for model "auto" with V2 Auto.
type AutoTier = rpc.AutoTier

const (
// AutoTierEfficiency selects the efficiency routing tier.
AutoTierEfficiency = rpc.AutoTierEfficiency
// AutoTierBalance selects the balance routing tier.
AutoTierBalance = rpc.AutoTierBalance
// AutoTierIntelligence selects the intelligence routing tier.
AutoTierIntelligence = rpc.AutoTierIntelligence
)

// CapiSessionOptions configures provider-scoped Copilot API (CAPI) session behavior.
//
// WebSocket transport is the default for the CAPI Responses API whenever the
Expand All @@ -2239,6 +2251,14 @@ type CapiSessionOptions struct {
// WebSocket transport. Enabled by default when the model advertises
// ws:/responses support; set to Bool(false) to force HTTP Responses transport.
EnableWebSocketResponses *bool `json:"enableWebSocketResponses,omitempty"`

// AutoTier selects the routing tier for model "auto" with V2 Auto.
// Requires a runtime that supports Auto tiers; it has no effect outside V2 Auto.
// When unset, the runtime uses its default on create and preserves the
// persisted or current tier on resume. An explicit tier overrides the
// persisted tier on a cold resume; a conflicting tier on a resident
// session resume is rejected by the runtime.
AutoTier AutoTier `json:"autoTier,omitempty"`
}

// AzureProviderOptions contains Azure-specific provider configuration
Expand Down
Loading
Loading