Skip to content
Merged
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
4 changes: 2 additions & 2 deletions dotnet/src/Generated/Rpc.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 29 additions & 3 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1576,14 +1576,40 @@ public async Task AbortAsync(CancellationToken cancellationToken = default)
/// <code>
/// await session.SetModelAsync("gpt-4.1");
/// await session.SetModelAsync("claude-sonnet-4.6", "high");
/// await session.SetModelAsync("gpt-4.1", new SetModelOptions { ContextTier = ContextTier.LongContext });
/// </code>
/// </example>
public async Task SetModelAsync(string model, string? reasoningEffort, ModelCapabilitiesOverride? modelCapabilities = null, CancellationToken cancellationToken = default)
public Task SetModelAsync(string model, string? reasoningEffort, ModelCapabilitiesOverride? modelCapabilities = null, CancellationToken cancellationToken = default)
{
return SetModelAsync(
model,
new SetModelOptions
{
ReasoningEffort = reasoningEffort,
ModelCapabilities = modelCapabilities,
},
cancellationToken);
}

/// <summary>
/// Changes the model for this session.
/// The new model takes effect for the next message. Conversation history is preserved.
/// </summary>
/// <param name="model">Model ID to switch to (e.g., "gpt-4.1").</param>
/// <param name="options">Settings for the new model.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
public async Task SetModelAsync(string model, SetModelOptions options, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(model);
ThrowIfDisposed();

await Rpc.Model.SwitchToAsync(model, reasoningEffort, reasoningSummary: null, modelCapabilities: modelCapabilities, cancellationToken: cancellationToken);
await Rpc.Model.SwitchToAsync(
model,
options.ReasoningEffort,
options.ReasoningSummary,
options.ModelCapabilities,
options.ContextTier,
cancellationToken);
}

/// <summary>
Expand All @@ -1593,7 +1619,7 @@ public Task SetModelAsync(string model, CancellationToken cancellationToken = de
{
ThrowIfDisposed();

return SetModelAsync(model, reasoningEffort: null, modelCapabilities: null, cancellationToken);
return SetModelAsync(model, new SetModelOptions(), cancellationToken);
}

/// <summary>
Expand Down
28 changes: 28 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2390,6 +2390,34 @@ public sealed class CloudSessionOptions
public CloudSessionRepository? Repository { get; set; }
}

/// <summary>
/// Optional settings for <see cref="CopilotSession.SetModelAsync(string, SetModelOptions, CancellationToken)"/>.
/// </summary>
public struct SetModelOptions
{
/// <summary>
/// Reasoning effort level for the new model.
/// </summary>
public string? ReasoningEffort { get; set; }

/// <summary>
/// Reasoning summary mode for models that support configurable reasoning summaries.
/// </summary>
/// <remarks>
/// Use <see cref="ReasoningSummary.None"/> to suppress summary output regardless of whether reasoning is enabled.
/// </remarks>
public ReasoningSummary? ReasoningSummary { get; set; }

/// <summary>
/// Explicit context window tier for models that support it.
/// Leave unset to use normal model behavior with no explicit tier.
/// </summary>
public ContextTier? ContextTier { get; set; }

/// <summary>Per-property overrides for model capabilities, deep-merged over runtime defaults.</summary>
public ModelCapabilitiesOverride? ModelCapabilities { get; set; }
}

/// <summary>
/// Shared configuration properties for creating or resuming a Copilot session.
/// Use <see cref="SessionConfig"/> when creating a new session, or
Expand Down
5 changes: 2 additions & 3 deletions go/rpc/zrpc.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions go/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -1495,6 +1495,9 @@ type SetModelOptions struct {
// ReasoningSummary sets the reasoning summary mode for the new model.
// Use ReasoningSummaryNone to suppress summary output regardless of whether reasoning is enabled.
ReasoningSummary *ReasoningSummary
// ContextTier explicitly selects a context window tier for models that support it.
// Leave nil to use normal model behavior with no explicit tier.
ContextTier *ContextTier
// ModelCapabilities overrides individual model capabilities resolved by the runtime.
// Only non-nil fields are applied over the runtime-resolved capabilities.
ModelCapabilities *rpc.ModelCapabilitiesOverride
Expand All @@ -1516,6 +1519,7 @@ func (s *Session) SetModel(ctx context.Context, model string, opts *SetModelOpti
if opts != nil {
params.ReasoningEffort = opts.ReasoningEffort
params.ReasoningSummary = opts.ReasoningSummary
params.ContextTier = opts.ContextTier
params.ModelCapabilities = opts.ModelCapabilities
}
_, err := s.RPC.Model.SwitchTo(ctx, params)
Expand Down
137 changes: 137 additions & 0 deletions go/session_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
package copilot

import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"

"github.com/github/copilot-sdk/go/internal/jsonrpc2"
"github.com/github/copilot-sdk/go/rpc"
)

// newTestSession creates a session with an event channel and starts the consumer goroutine.
Expand All @@ -30,6 +37,136 @@ func ptr[T any](value T) *T {
return &value
}

func TestSession_SetModelForwardsContextTier(t *testing.T) {
tier := ContextTierLongContext
params := captureSetModelRequest(t, &SetModelOptions{ContextTier: &tier})

if params["sessionId"] != "session-1" {
t.Fatalf("expected sessionId session-1, got %v", params["sessionId"])
}
if params["modelId"] != "gpt-4.1" {
t.Fatalf("expected modelId gpt-4.1, got %v", params["modelId"])
}
if params["contextTier"] != "long_context" {
t.Fatalf("expected contextTier long_context, got %v", params["contextTier"])
}
}

func TestSession_SetModelOmitsContextTierWhenUnset(t *testing.T) {
params := captureSetModelRequest(t, nil)

if _, ok := params["contextTier"]; ok {
t.Fatalf("expected contextTier to be omitted, got %v", params["contextTier"])
}
}

func captureSetModelRequest(t *testing.T, opts *SetModelOptions) map[string]any {
t.Helper()

stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
defer stdinR.Close()
defer stdinW.Close()
defer stdoutR.Close()
defer stdoutW.Close()

client := jsonrpc2.NewClient(stdinW, stdoutR)
client.Start()
defer client.Stop()

paramsCh := make(chan map[string]any, 1)
errCh := make(chan error, 1)

go func() {
frame, err := readTestJSONRPCFrame(stdinR)
if err != nil {
errCh <- err
return
}

var request struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params map[string]any `json:"params"`
}
if err := json.Unmarshal(frame, &request); err != nil {
errCh <- err
return
}
if request.Method != "session.model.switchTo" {
errCh <- fmt.Errorf("expected session.model.switchTo, got %s", request.Method)
return
}

paramsCh <- request.Params

response := map[string]any{
"jsonrpc": "2.0",
"id": json.RawMessage(request.ID),
"result": map[string]any{},
}
data, err := json.Marshal(response)
if err != nil {
errCh <- err
return
}
if _, err := fmt.Fprintf(stdoutW, "Content-Length: %d\r\n\r\n%s", len(data), data); err != nil {
errCh <- err
return
}
}()

session := &Session{
SessionID: "session-1",
client: client,
RPC: rpc.NewSessionRpc(client, "session-1"),
}
if err := session.SetModel(context.Background(), "gpt-4.1", opts); err != nil {
t.Fatalf("SetModel failed: %v", err)
}

select {
case params := <-paramsCh:
return params
case err := <-errCh:
t.Fatal(err)
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for session.model.switchTo request")
}
return nil
}

func readTestJSONRPCFrame(r io.Reader) ([]byte, error) {
reader := bufio.NewReader(r)
var contentLength int
for {
line, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimSpace(line)
if line == "" {
break
}
name, value, ok := strings.Cut(line, ":")
if !ok {
return nil, fmt.Errorf("invalid header line %q", line)
}
if name == "Content-Length" {
contentLength, err = strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return nil, err
}
}
}
if contentLength == 0 {
return nil, fmt.Errorf("missing Content-Length header")
}
data := make([]byte, contentLength)
_, err := io.ReadFull(reader, data)
return data, err
}

func TestSession_On(t *testing.T) {
t.Run("multiple handlers all receive events", func(t *testing.T) {
session, cleanup := newTestSession()
Expand Down
Loading
Loading