diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index ac010ed86..677aecfd2 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -149,7 +149,7 @@ public class ToolsListResult public List Tools { get; set; } = new(); } -internal class ListRequest +internal class ToolsListRequest { [JsonPropertyName("model")] public string? Model { get; set; } @@ -195,7 +195,7 @@ public class SessionModelGetCurrentResult public string? ModelId { get; set; } } -internal class GetCurrentRequest +internal class SessionModelGetCurrentRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -207,7 +207,7 @@ public class SessionModelSwitchToResult public string? ModelId { get; set; } } -internal class SwitchToRequest +internal class SessionModelSwitchToRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -223,7 +223,7 @@ public class SessionModeGetResult public SessionModeGetResultMode Mode { get; set; } } -internal class GetRequest +internal class SessionModeGetRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -236,7 +236,7 @@ public class SessionModeSetResult public SessionModeGetResultMode Mode { get; set; } } -internal class SetRequest +internal class SessionModeSetRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -256,7 +256,7 @@ public class SessionPlanReadResult public string? Content { get; set; } } -internal class ReadRequest +internal class SessionPlanReadRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -266,7 +266,7 @@ public class SessionPlanUpdateResult { } -internal class UpdateRequest +internal class SessionPlanUpdateRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -279,7 +279,7 @@ public class SessionPlanDeleteResult { } -internal class DeleteRequest +internal class SessionPlanDeleteRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -292,7 +292,7 @@ public class SessionWorkspaceListFilesResult public List Files { get; set; } = new(); } -internal class ListFilesRequest +internal class SessionWorkspaceListFilesRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -305,7 +305,7 @@ public class SessionWorkspaceReadFileResult public string Content { get; set; } = string.Empty; } -internal class ReadFileRequest +internal class SessionWorkspaceReadFileRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -318,7 +318,7 @@ public class SessionWorkspaceCreateFileResult { } -internal class CreateFileRequest +internal class SessionWorkspaceCreateFileRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -337,7 +337,7 @@ public class SessionFleetStartResult public bool Started { get; set; } } -internal class StartRequest +internal class SessionFleetStartRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -346,6 +346,125 @@ internal class StartRequest public string? Prompt { get; set; } } +public class Agent +{ + /// Unique identifier of the custom agent + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Human-readable display name + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + /// Description of the agent's purpose + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; +} + +public class SessionAgentListResult +{ + /// Available custom agents + [JsonPropertyName("agents")] + public List Agents { get; set; } = new(); +} + +internal class SessionAgentListRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +public class SessionAgentGetCurrentResultAgent +{ + /// Unique identifier of the custom agent + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Human-readable display name + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + /// Description of the agent's purpose + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; +} + +public class SessionAgentGetCurrentResult +{ + /// Currently selected custom agent, or null if using the default agent + [JsonPropertyName("agent")] + public SessionAgentGetCurrentResultAgent? Agent { get; set; } +} + +internal class SessionAgentGetCurrentRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// The newly selected custom agent +public class SessionAgentSelectResultAgent +{ + /// Unique identifier of the custom agent + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Human-readable display name + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + /// Description of the agent's purpose + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; +} + +public class SessionAgentSelectResult +{ + /// The newly selected custom agent + [JsonPropertyName("agent")] + public SessionAgentSelectResultAgent Agent { get; set; } = new(); +} + +internal class SessionAgentSelectRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; +} + +public class SessionAgentDeselectResult +{ +} + +internal class SessionAgentDeselectRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +public class SessionCompactionCompactResult +{ + /// Whether compaction completed successfully + [JsonPropertyName("success")] + public bool Success { get; set; } + + /// Number of tokens freed by compaction + [JsonPropertyName("tokensRemoved")] + public double TokensRemoved { get; set; } + + /// Number of messages removed during compaction + [JsonPropertyName("messagesRemoved")] + public double MessagesRemoved { get; set; } +} + +internal class SessionCompactionCompactRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum SessionModeGetResultMode { @@ -418,7 +537,7 @@ internal ToolsApi(JsonRpc rpc) /// Calls "tools.list". public async Task ListAsync(string? model = null, CancellationToken cancellationToken = default) { - var request = new ListRequest { Model = model }; + var request = new ToolsListRequest { Model = model }; return await CopilotClient.InvokeRpcAsync(_rpc, "tools.list", [request], cancellationToken); } } @@ -455,6 +574,8 @@ internal SessionRpc(JsonRpc rpc, string sessionId) Plan = new PlanApi(rpc, sessionId); Workspace = new WorkspaceApi(rpc, sessionId); Fleet = new FleetApi(rpc, sessionId); + Agent = new AgentApi(rpc, sessionId); + Compaction = new CompactionApi(rpc, sessionId); } public ModelApi Model { get; } @@ -466,6 +587,10 @@ internal SessionRpc(JsonRpc rpc, string sessionId) public WorkspaceApi Workspace { get; } public FleetApi Fleet { get; } + + public AgentApi Agent { get; } + + public CompactionApi Compaction { get; } } public class ModelApi @@ -482,14 +607,14 @@ internal ModelApi(JsonRpc rpc, string sessionId) /// Calls "session.model.getCurrent". public async Task GetCurrentAsync(CancellationToken cancellationToken = default) { - var request = new GetCurrentRequest { SessionId = _sessionId }; + var request = new SessionModelGetCurrentRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.model.getCurrent", [request], cancellationToken); } /// Calls "session.model.switchTo". public async Task SwitchToAsync(string modelId, CancellationToken cancellationToken = default) { - var request = new SwitchToRequest { SessionId = _sessionId, ModelId = modelId }; + var request = new SessionModelSwitchToRequest { SessionId = _sessionId, ModelId = modelId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.model.switchTo", [request], cancellationToken); } } @@ -508,14 +633,14 @@ internal ModeApi(JsonRpc rpc, string sessionId) /// Calls "session.mode.get". public async Task GetAsync(CancellationToken cancellationToken = default) { - var request = new GetRequest { SessionId = _sessionId }; + var request = new SessionModeGetRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.mode.get", [request], cancellationToken); } /// Calls "session.mode.set". public async Task SetAsync(SessionModeGetResultMode mode, CancellationToken cancellationToken = default) { - var request = new SetRequest { SessionId = _sessionId, Mode = mode }; + var request = new SessionModeSetRequest { SessionId = _sessionId, Mode = mode }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.mode.set", [request], cancellationToken); } } @@ -534,21 +659,21 @@ internal PlanApi(JsonRpc rpc, string sessionId) /// Calls "session.plan.read". public async Task ReadAsync(CancellationToken cancellationToken = default) { - var request = new ReadRequest { SessionId = _sessionId }; + var request = new SessionPlanReadRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.plan.read", [request], cancellationToken); } /// Calls "session.plan.update". public async Task UpdateAsync(string content, CancellationToken cancellationToken = default) { - var request = new UpdateRequest { SessionId = _sessionId, Content = content }; + var request = new SessionPlanUpdateRequest { SessionId = _sessionId, Content = content }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.plan.update", [request], cancellationToken); } /// Calls "session.plan.delete". public async Task DeleteAsync(CancellationToken cancellationToken = default) { - var request = new DeleteRequest { SessionId = _sessionId }; + var request = new SessionPlanDeleteRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.plan.delete", [request], cancellationToken); } } @@ -567,21 +692,21 @@ internal WorkspaceApi(JsonRpc rpc, string sessionId) /// Calls "session.workspace.listFiles". public async Task ListFilesAsync(CancellationToken cancellationToken = default) { - var request = new ListFilesRequest { SessionId = _sessionId }; + var request = new SessionWorkspaceListFilesRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.workspace.listFiles", [request], cancellationToken); } /// Calls "session.workspace.readFile". public async Task ReadFileAsync(string path, CancellationToken cancellationToken = default) { - var request = new ReadFileRequest { SessionId = _sessionId, Path = path }; + var request = new SessionWorkspaceReadFileRequest { SessionId = _sessionId, Path = path }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.workspace.readFile", [request], cancellationToken); } /// Calls "session.workspace.createFile". public async Task CreateFileAsync(string path, string content, CancellationToken cancellationToken = default) { - var request = new CreateFileRequest { SessionId = _sessionId, Path = path, Content = content }; + var request = new SessionWorkspaceCreateFileRequest { SessionId = _sessionId, Path = path, Content = content }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.workspace.createFile", [request], cancellationToken); } } @@ -600,23 +725,77 @@ internal FleetApi(JsonRpc rpc, string sessionId) /// Calls "session.fleet.start". public async Task StartAsync(string? prompt, CancellationToken cancellationToken = default) { - var request = new StartRequest { SessionId = _sessionId, Prompt = prompt }; + var request = new SessionFleetStartRequest { SessionId = _sessionId, Prompt = prompt }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.fleet.start", [request], cancellationToken); } } +public class AgentApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal AgentApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.agent.list". + public async Task ListAsync(CancellationToken cancellationToken = default) + { + var request = new SessionAgentListRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.agent.list", [request], cancellationToken); + } + + /// Calls "session.agent.getCurrent". + public async Task GetCurrentAsync(CancellationToken cancellationToken = default) + { + var request = new SessionAgentGetCurrentRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.agent.getCurrent", [request], cancellationToken); + } + + /// Calls "session.agent.select". + public async Task SelectAsync(string name, CancellationToken cancellationToken = default) + { + var request = new SessionAgentSelectRequest { SessionId = _sessionId, Name = name }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.agent.select", [request], cancellationToken); + } + + /// Calls "session.agent.deselect". + public async Task DeselectAsync(CancellationToken cancellationToken = default) + { + var request = new SessionAgentDeselectRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.agent.deselect", [request], cancellationToken); + } +} + +public class CompactionApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal CompactionApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.compaction.compact". + public async Task CompactAsync(CancellationToken cancellationToken = default) + { + var request = new SessionCompactionCompactRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.compaction.compact", [request], cancellationToken); + } +} + [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(AccountGetQuotaResult))] [JsonSerializable(typeof(AccountGetQuotaResultQuotaSnapshotsValue))] -[JsonSerializable(typeof(CreateFileRequest))] -[JsonSerializable(typeof(DeleteRequest))] -[JsonSerializable(typeof(GetCurrentRequest))] -[JsonSerializable(typeof(GetRequest))] -[JsonSerializable(typeof(ListFilesRequest))] -[JsonSerializable(typeof(ListRequest))] +[JsonSerializable(typeof(Agent))] [JsonSerializable(typeof(Model))] [JsonSerializable(typeof(ModelBilling))] [JsonSerializable(typeof(ModelCapabilities))] @@ -626,23 +805,41 @@ public async Task StartAsync(string? prompt, Cancellati [JsonSerializable(typeof(ModelsListResult))] [JsonSerializable(typeof(PingRequest))] [JsonSerializable(typeof(PingResult))] -[JsonSerializable(typeof(ReadFileRequest))] -[JsonSerializable(typeof(ReadRequest))] +[JsonSerializable(typeof(SessionAgentDeselectRequest))] +[JsonSerializable(typeof(SessionAgentDeselectResult))] +[JsonSerializable(typeof(SessionAgentGetCurrentRequest))] +[JsonSerializable(typeof(SessionAgentGetCurrentResult))] +[JsonSerializable(typeof(SessionAgentGetCurrentResultAgent))] +[JsonSerializable(typeof(SessionAgentListRequest))] +[JsonSerializable(typeof(SessionAgentListResult))] +[JsonSerializable(typeof(SessionAgentSelectRequest))] +[JsonSerializable(typeof(SessionAgentSelectResult))] +[JsonSerializable(typeof(SessionAgentSelectResultAgent))] +[JsonSerializable(typeof(SessionCompactionCompactRequest))] +[JsonSerializable(typeof(SessionCompactionCompactResult))] +[JsonSerializable(typeof(SessionFleetStartRequest))] [JsonSerializable(typeof(SessionFleetStartResult))] +[JsonSerializable(typeof(SessionModeGetRequest))] [JsonSerializable(typeof(SessionModeGetResult))] +[JsonSerializable(typeof(SessionModeSetRequest))] [JsonSerializable(typeof(SessionModeSetResult))] +[JsonSerializable(typeof(SessionModelGetCurrentRequest))] [JsonSerializable(typeof(SessionModelGetCurrentResult))] +[JsonSerializable(typeof(SessionModelSwitchToRequest))] [JsonSerializable(typeof(SessionModelSwitchToResult))] +[JsonSerializable(typeof(SessionPlanDeleteRequest))] [JsonSerializable(typeof(SessionPlanDeleteResult))] +[JsonSerializable(typeof(SessionPlanReadRequest))] [JsonSerializable(typeof(SessionPlanReadResult))] +[JsonSerializable(typeof(SessionPlanUpdateRequest))] [JsonSerializable(typeof(SessionPlanUpdateResult))] +[JsonSerializable(typeof(SessionWorkspaceCreateFileRequest))] [JsonSerializable(typeof(SessionWorkspaceCreateFileResult))] +[JsonSerializable(typeof(SessionWorkspaceListFilesRequest))] [JsonSerializable(typeof(SessionWorkspaceListFilesResult))] +[JsonSerializable(typeof(SessionWorkspaceReadFileRequest))] [JsonSerializable(typeof(SessionWorkspaceReadFileResult))] -[JsonSerializable(typeof(SetRequest))] -[JsonSerializable(typeof(StartRequest))] -[JsonSerializable(typeof(SwitchToRequest))] [JsonSerializable(typeof(Tool))] +[JsonSerializable(typeof(ToolsListRequest))] [JsonSerializable(typeof(ToolsListResult))] -[JsonSerializable(typeof(UpdateRequest))] internal partial class RpcJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/dotnet/test/AgentAndCompactRpcTests.cs b/dotnet/test/AgentAndCompactRpcTests.cs new file mode 100644 index 000000000..adbe40d89 --- /dev/null +++ b/dotnet/test/AgentAndCompactRpcTests.cs @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.SDK.Rpc; +using GitHub.Copilot.SDK.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.SDK.Test; + +public class AgentAndCompactRpcTests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "agent_and_compact_rpc", output) +{ + [Fact] + public async Task Should_List_Available_Custom_Agents() + { + var customAgents = new List + { + new() + { + Name = "test-agent", + DisplayName = "Test Agent", + Description = "A test agent", + Prompt = "You are a test agent." + }, + new() + { + Name = "another-agent", + DisplayName = "Another Agent", + Description = "Another test agent", + Prompt = "You are another agent." + } + }; + + var session = await Client.CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); + + var result = await session.Rpc.Agent.ListAsync(); + Assert.NotNull(result.Agents); + Assert.Equal(2, result.Agents.Count); + Assert.Equal("test-agent", result.Agents[0].Name); + Assert.Equal("Test Agent", result.Agents[0].DisplayName); + Assert.Equal("A test agent", result.Agents[0].Description); + Assert.Equal("another-agent", result.Agents[1].Name); + } + + [Fact] + public async Task Should_Return_Null_When_No_Agent_Is_Selected() + { + var customAgents = new List + { + new() + { + Name = "test-agent", + DisplayName = "Test Agent", + Description = "A test agent", + Prompt = "You are a test agent." + } + }; + + var session = await Client.CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); + + var result = await session.Rpc.Agent.GetCurrentAsync(); + Assert.Null(result.Agent); + } + + [Fact] + public async Task Should_Select_And_Get_Current_Agent() + { + var customAgents = new List + { + new() + { + Name = "test-agent", + DisplayName = "Test Agent", + Description = "A test agent", + Prompt = "You are a test agent." + } + }; + + var session = await Client.CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); + + // Select the agent + var selectResult = await session.Rpc.Agent.SelectAsync("test-agent"); + Assert.NotNull(selectResult.Agent); + Assert.Equal("test-agent", selectResult.Agent.Name); + Assert.Equal("Test Agent", selectResult.Agent.DisplayName); + + // Verify getCurrent returns the selected agent + var currentResult = await session.Rpc.Agent.GetCurrentAsync(); + Assert.NotNull(currentResult.Agent); + Assert.Equal("test-agent", currentResult.Agent.Name); + } + + [Fact] + public async Task Should_Deselect_Current_Agent() + { + var customAgents = new List + { + new() + { + Name = "test-agent", + DisplayName = "Test Agent", + Description = "A test agent", + Prompt = "You are a test agent." + } + }; + + var session = await Client.CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); + + // Select then deselect + await session.Rpc.Agent.SelectAsync("test-agent"); + await session.Rpc.Agent.DeselectAsync(); + + // Verify no agent is selected + var currentResult = await session.Rpc.Agent.GetCurrentAsync(); + Assert.Null(currentResult.Agent); + } + + [Fact] + public async Task Should_Return_Empty_List_When_No_Custom_Agents_Configured() + { + var session = await Client.CreateSessionAsync(); + + var result = await session.Rpc.Agent.ListAsync(); + Assert.Empty(result.Agents); + } + + [Fact] + public async Task Should_Compact_Session_History_After_Messages() + { + var session = await Client.CreateSessionAsync(); + + // Send a message to create some history + await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" }); + + // Compact the session + var result = await session.Rpc.Compaction.CompactAsync(); + Assert.NotNull(result); + } +} diff --git a/go/internal/e2e/agent_and_compact_rpc_test.go b/go/internal/e2e/agent_and_compact_rpc_test.go new file mode 100644 index 000000000..829f1309f --- /dev/null +++ b/go/internal/e2e/agent_and_compact_rpc_test.go @@ -0,0 +1,281 @@ +package e2e + +import ( + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestAgentSelectionRpc(t *testing.T) { + cliPath := testharness.CLIPath() + if cliPath == "" { + t.Fatal("CLI not found. Run 'npm install' in the nodejs directory first.") + } + + t.Run("should list available custom agents", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "test-agent", + DisplayName: "Test Agent", + Description: "A test agent", + Prompt: "You are a test agent.", + }, + { + Name: "another-agent", + DisplayName: "Another Agent", + Description: "Another test agent", + Prompt: "You are another agent.", + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + result, err := session.RPC.Agent.List(t.Context()) + if err != nil { + t.Fatalf("Failed to list agents: %v", err) + } + + if len(result.Agents) != 2 { + t.Fatalf("Expected 2 agents, got %d", len(result.Agents)) + } + if result.Agents[0].Name != "test-agent" { + t.Errorf("Expected first agent name 'test-agent', got %q", result.Agents[0].Name) + } + if result.Agents[0].DisplayName != "Test Agent" { + t.Errorf("Expected first agent displayName 'Test Agent', got %q", result.Agents[0].DisplayName) + } + if result.Agents[1].Name != "another-agent" { + t.Errorf("Expected second agent name 'another-agent', got %q", result.Agents[1].Name) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + t.Run("should return null when no agent is selected", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "test-agent", + DisplayName: "Test Agent", + Description: "A test agent", + Prompt: "You are a test agent.", + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + result, err := session.RPC.Agent.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Failed to get current agent: %v", err) + } + + if result.Agent != nil { + t.Errorf("Expected no agent selected, got %v", result.Agent) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + t.Run("should select and get current agent", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "test-agent", + DisplayName: "Test Agent", + Description: "A test agent", + Prompt: "You are a test agent.", + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Select the agent + selectResult, err := session.RPC.Agent.Select(t.Context(), &rpc.SessionAgentSelectParams{Name: "test-agent"}) + if err != nil { + t.Fatalf("Failed to select agent: %v", err) + } + if selectResult.Agent.Name != "test-agent" { + t.Errorf("Expected selected agent 'test-agent', got %q", selectResult.Agent.Name) + } + if selectResult.Agent.DisplayName != "Test Agent" { + t.Errorf("Expected displayName 'Test Agent', got %q", selectResult.Agent.DisplayName) + } + + // Verify getCurrent returns the selected agent + currentResult, err := session.RPC.Agent.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Failed to get current agent: %v", err) + } + if currentResult.Agent == nil { + t.Fatal("Expected an agent to be selected") + } + if currentResult.Agent.Name != "test-agent" { + t.Errorf("Expected current agent 'test-agent', got %q", currentResult.Agent.Name) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + t.Run("should deselect current agent", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "test-agent", + DisplayName: "Test Agent", + Description: "A test agent", + Prompt: "You are a test agent.", + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Select then deselect + _, err = session.RPC.Agent.Select(t.Context(), &rpc.SessionAgentSelectParams{Name: "test-agent"}) + if err != nil { + t.Fatalf("Failed to select agent: %v", err) + } + + _, err = session.RPC.Agent.Deselect(t.Context()) + if err != nil { + t.Fatalf("Failed to deselect agent: %v", err) + } + + // Verify no agent is selected + currentResult, err := session.RPC.Agent.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Failed to get current agent: %v", err) + } + if currentResult.Agent != nil { + t.Errorf("Expected no agent selected after deselect, got %v", currentResult.Agent) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + t.Run("should return empty list when no custom agents configured", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), nil) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + result, err := session.RPC.Agent.List(t.Context()) + if err != nil { + t.Fatalf("Failed to list agents: %v", err) + } + + if len(result.Agents) != 0 { + t.Errorf("Expected empty agent list, got %d agents", len(result.Agents)) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) +} + +func TestSessionCompactionRpc(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + t.Run("should compact session history after messages", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), nil) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Send a message to create some history + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "What is 2+2?", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // Compact the session + result, err := session.RPC.Compaction.Compact(t.Context()) + if err != nil { + t.Fatalf("Failed to compact session: %v", err) + } + + // Verify result has expected fields (just check it returned valid data) + if result == nil { + t.Fatal("Expected non-nil compact result") + } + }) +} diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go index c7d9b0c07..eaf409a85 100644 --- a/go/rpc/generated_rpc.go +++ b/go/rpc/generated_rpc.go @@ -200,6 +200,66 @@ type SessionFleetStartParams struct { Prompt *string `json:"prompt,omitempty"` } +type SessionAgentListResult struct { + // Available custom agents + Agents []AgentElement `json:"agents"` +} + +type AgentElement struct { + // Description of the agent's purpose + Description string `json:"description"` + // Human-readable display name + DisplayName string `json:"displayName"` + // Unique identifier of the custom agent + Name string `json:"name"` +} + +type SessionAgentGetCurrentResult struct { + // Currently selected custom agent, or null if using the default agent + Agent *SessionAgentGetCurrentResultAgent `json:"agent"` +} + +type SessionAgentGetCurrentResultAgent struct { + // Description of the agent's purpose + Description string `json:"description"` + // Human-readable display name + DisplayName string `json:"displayName"` + // Unique identifier of the custom agent + Name string `json:"name"` +} + +type SessionAgentSelectResult struct { + // The newly selected custom agent + Agent SessionAgentSelectResultAgent `json:"agent"` +} + +// The newly selected custom agent +type SessionAgentSelectResultAgent struct { + // Description of the agent's purpose + Description string `json:"description"` + // Human-readable display name + DisplayName string `json:"displayName"` + // Unique identifier of the custom agent + Name string `json:"name"` +} + +type SessionAgentSelectParams struct { + // Name of the custom agent to select + Name string `json:"name"` +} + +type SessionAgentDeselectResult struct { +} + +type SessionCompactionCompactResult struct { + // Number of messages removed during compaction + MessagesRemoved float64 `json:"messagesRemoved"` + // Whether compaction completed successfully + Success bool `json:"success"` + // Number of tokens freed by compaction + TokensRemoved float64 `json:"tokensRemoved"` +} + // The current agent mode. // // The agent mode after switching. @@ -472,23 +532,105 @@ func (a *FleetRpcApi) Start(ctx context.Context, params *SessionFleetStartParams return &result, nil } -// SessionRpc provides typed session-scoped RPC methods. -type SessionRpc struct { +type AgentRpcApi struct { client *jsonrpc2.Client sessionID string - Model *ModelRpcApi - Mode *ModeRpcApi - Plan *PlanRpcApi - Workspace *WorkspaceRpcApi - Fleet *FleetRpcApi +} + +func (a *AgentRpcApi) List(ctx context.Context) (*SessionAgentListResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.agent.list", req) + if err != nil { + return nil, err + } + var result SessionAgentListResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *AgentRpcApi) GetCurrent(ctx context.Context) (*SessionAgentGetCurrentResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.agent.getCurrent", req) + if err != nil { + return nil, err + } + var result SessionAgentGetCurrentResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *AgentRpcApi) Select(ctx context.Context, params *SessionAgentSelectParams) (*SessionAgentSelectResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["name"] = params.Name + } + raw, err := a.client.Request("session.agent.select", req) + if err != nil { + return nil, err + } + var result SessionAgentSelectResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *AgentRpcApi) Deselect(ctx context.Context) (*SessionAgentDeselectResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.agent.deselect", req) + if err != nil { + return nil, err + } + var result SessionAgentDeselectResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +type CompactionRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *CompactionRpcApi) Compact(ctx context.Context) (*SessionCompactionCompactResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.compaction.compact", req) + if err != nil { + return nil, err + } + var result SessionCompactionCompactResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// SessionRpc provides typed session-scoped RPC methods. +type SessionRpc struct { + client *jsonrpc2.Client + sessionID string + Model *ModelRpcApi + Mode *ModeRpcApi + Plan *PlanRpcApi + Workspace *WorkspaceRpcApi + Fleet *FleetRpcApi + Agent *AgentRpcApi + Compaction *CompactionRpcApi } func NewSessionRpc(client *jsonrpc2.Client, sessionID string) *SessionRpc { return &SessionRpc{client: client, sessionID: sessionID, - Model: &ModelRpcApi{client: client, sessionID: sessionID}, - Mode: &ModeRpcApi{client: client, sessionID: sessionID}, - Plan: &PlanRpcApi{client: client, sessionID: sessionID}, - Workspace: &WorkspaceRpcApi{client: client, sessionID: sessionID}, - Fleet: &FleetRpcApi{client: client, sessionID: sessionID}, + Model: &ModelRpcApi{client: client, sessionID: sessionID}, + Mode: &ModeRpcApi{client: client, sessionID: sessionID}, + Plan: &PlanRpcApi{client: client, sessionID: sessionID}, + Workspace: &WorkspaceRpcApi{client: client, sessionID: sessionID}, + Fleet: &FleetRpcApi{client: client, sessionID: sessionID}, + Agent: &AgentRpcApi{client: client, sessionID: sessionID}, + Compaction: &CompactionRpcApi{client: client, sessionID: sessionID}, } } diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 12c992bd6..861292620 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -314,6 +314,122 @@ export interface SessionFleetStartParams { prompt?: string; } +export interface SessionAgentListResult { + /** + * Available custom agents + */ + agents: { + /** + * Unique identifier of the custom agent + */ + name: string; + /** + * Human-readable display name + */ + displayName: string; + /** + * Description of the agent's purpose + */ + description: string; + }[]; +} + +export interface SessionAgentListParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionAgentGetCurrentResult { + /** + * Currently selected custom agent, or null if using the default agent + */ + agent: { + /** + * Unique identifier of the custom agent + */ + name: string; + /** + * Human-readable display name + */ + displayName: string; + /** + * Description of the agent's purpose + */ + description: string; + } | null; +} + +export interface SessionAgentGetCurrentParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionAgentSelectResult { + /** + * The newly selected custom agent + */ + agent: { + /** + * Unique identifier of the custom agent + */ + name: string; + /** + * Human-readable display name + */ + displayName: string; + /** + * Description of the agent's purpose + */ + description: string; + }; +} + +export interface SessionAgentSelectParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Name of the custom agent to select + */ + name: string; +} + +export interface SessionAgentDeselectResult {} + +export interface SessionAgentDeselectParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionCompactionCompactResult { + /** + * Whether compaction completed successfully + */ + success: boolean; + /** + * Number of tokens freed by compaction + */ + tokensRemoved: number; + /** + * Number of messages removed during compaction + */ + messagesRemoved: number; +} + +export interface SessionCompactionCompactParams { + /** + * Target session identifier + */ + sessionId: string; +} + /** Create typed server-scoped RPC methods (no session required). */ export function createServerRpc(connection: MessageConnection) { return { @@ -369,5 +485,19 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin start: async (params: Omit): Promise => connection.sendRequest("session.fleet.start", { sessionId, ...params }), }, + agent: { + list: async (): Promise => + connection.sendRequest("session.agent.list", { sessionId }), + getCurrent: async (): Promise => + connection.sendRequest("session.agent.getCurrent", { sessionId }), + select: async (params: Omit): Promise => + connection.sendRequest("session.agent.select", { sessionId, ...params }), + deselect: async (): Promise => + connection.sendRequest("session.agent.deselect", { sessionId }), + }, + compaction: { + compact: async (): Promise => + connection.sendRequest("session.compaction.compact", { sessionId }), + }, }; } diff --git a/nodejs/test/e2e/agent_and_compact_rpc.test.ts b/nodejs/test/e2e/agent_and_compact_rpc.test.ts new file mode 100644 index 000000000..c6fda00b0 --- /dev/null +++ b/nodejs/test/e2e/agent_and_compact_rpc.test.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import type { CustomAgentConfig } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("Agent Selection RPC", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should list available custom agents", async () => { + const customAgents: CustomAgentConfig[] = [ + { + name: "test-agent", + displayName: "Test Agent", + description: "A test agent", + prompt: "You are a test agent.", + }, + { + name: "another-agent", + displayName: "Another Agent", + description: "Another test agent", + prompt: "You are another agent.", + }, + ]; + + const session = await client.createSession({ customAgents }); + + const result = await session.rpc.agent.list(); + expect(result.agents).toBeDefined(); + expect(Array.isArray(result.agents)).toBe(true); + expect(result.agents.length).toBe(2); + expect(result.agents[0].name).toBe("test-agent"); + expect(result.agents[0].displayName).toBe("Test Agent"); + expect(result.agents[0].description).toBe("A test agent"); + expect(result.agents[1].name).toBe("another-agent"); + + await session.destroy(); + }); + + it("should return null when no agent is selected", async () => { + const customAgents: CustomAgentConfig[] = [ + { + name: "test-agent", + displayName: "Test Agent", + description: "A test agent", + prompt: "You are a test agent.", + }, + ]; + + const session = await client.createSession({ customAgents }); + + const result = await session.rpc.agent.getCurrent(); + expect(result.agent).toBeNull(); + + await session.destroy(); + }); + + it("should select and get current agent", async () => { + const customAgents: CustomAgentConfig[] = [ + { + name: "test-agent", + displayName: "Test Agent", + description: "A test agent", + prompt: "You are a test agent.", + }, + ]; + + const session = await client.createSession({ customAgents }); + + // Select the agent + const selectResult = await session.rpc.agent.select({ name: "test-agent" }); + expect(selectResult.agent).toBeDefined(); + expect(selectResult.agent.name).toBe("test-agent"); + expect(selectResult.agent.displayName).toBe("Test Agent"); + + // Verify getCurrent returns the selected agent + const currentResult = await session.rpc.agent.getCurrent(); + expect(currentResult.agent).not.toBeNull(); + expect(currentResult.agent!.name).toBe("test-agent"); + + await session.destroy(); + }); + + it("should deselect current agent", async () => { + const customAgents: CustomAgentConfig[] = [ + { + name: "test-agent", + displayName: "Test Agent", + description: "A test agent", + prompt: "You are a test agent.", + }, + ]; + + const session = await client.createSession({ customAgents }); + + // Select then deselect + await session.rpc.agent.select({ name: "test-agent" }); + await session.rpc.agent.deselect(); + + // Verify no agent is selected + const currentResult = await session.rpc.agent.getCurrent(); + expect(currentResult.agent).toBeNull(); + + await session.destroy(); + }); + + it("should return empty list when no custom agents configured", async () => { + const session = await client.createSession(); + + const result = await session.rpc.agent.list(); + expect(result.agents).toEqual([]); + + await session.destroy(); + }); +}); + +describe("Session Compact RPC", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should compact session history after messages", async () => { + const session = await client.createSession(); + + // Send a message to create some history + await session.sendAndWait({ prompt: "What is 2+2?" }); + + // Compact the session + const result = await session.rpc.compaction.compact(); + expect(typeof result.success).toBe("boolean"); + expect(typeof result.tokensRemoved).toBe("number"); + expect(typeof result.messagesRemoved).toBe("number"); + + await session.destroy(); + }, 60000); +}); diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 3b87bea55..27a2bca2c 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -724,6 +724,196 @@ def to_dict(self) -> dict: return result +@dataclass +class AgentElement: + description: str + """Description of the agent's purpose""" + + display_name: str + """Human-readable display name""" + + name: str + """Unique identifier of the custom agent""" + + @staticmethod + def from_dict(obj: Any) -> 'AgentElement': + assert isinstance(obj, dict) + description = from_str(obj.get("description")) + display_name = from_str(obj.get("displayName")) + name = from_str(obj.get("name")) + return AgentElement(description, display_name, name) + + def to_dict(self) -> dict: + result: dict = {} + result["description"] = from_str(self.description) + result["displayName"] = from_str(self.display_name) + result["name"] = from_str(self.name) + return result + + +@dataclass +class SessionAgentListResult: + agents: List[AgentElement] + """Available custom agents""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentListResult': + assert isinstance(obj, dict) + agents = from_list(AgentElement.from_dict, obj.get("agents")) + return SessionAgentListResult(agents) + + def to_dict(self) -> dict: + result: dict = {} + result["agents"] = from_list(lambda x: to_class(AgentElement, x), self.agents) + return result + + +@dataclass +class SessionAgentGetCurrentResultAgent: + description: str + """Description of the agent's purpose""" + + display_name: str + """Human-readable display name""" + + name: str + """Unique identifier of the custom agent""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentGetCurrentResultAgent': + assert isinstance(obj, dict) + description = from_str(obj.get("description")) + display_name = from_str(obj.get("displayName")) + name = from_str(obj.get("name")) + return SessionAgentGetCurrentResultAgent(description, display_name, name) + + def to_dict(self) -> dict: + result: dict = {} + result["description"] = from_str(self.description) + result["displayName"] = from_str(self.display_name) + result["name"] = from_str(self.name) + return result + + +@dataclass +class SessionAgentGetCurrentResult: + agent: Optional[SessionAgentGetCurrentResultAgent] = None + """Currently selected custom agent, or null if using the default agent""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentGetCurrentResult': + assert isinstance(obj, dict) + agent = from_union([SessionAgentGetCurrentResultAgent.from_dict, from_none], obj.get("agent")) + return SessionAgentGetCurrentResult(agent) + + def to_dict(self) -> dict: + result: dict = {} + result["agent"] = from_union([lambda x: to_class(SessionAgentGetCurrentResultAgent, x), from_none], self.agent) + return result + + +@dataclass +class SessionAgentSelectResultAgent: + """The newly selected custom agent""" + + description: str + """Description of the agent's purpose""" + + display_name: str + """Human-readable display name""" + + name: str + """Unique identifier of the custom agent""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentSelectResultAgent': + assert isinstance(obj, dict) + description = from_str(obj.get("description")) + display_name = from_str(obj.get("displayName")) + name = from_str(obj.get("name")) + return SessionAgentSelectResultAgent(description, display_name, name) + + def to_dict(self) -> dict: + result: dict = {} + result["description"] = from_str(self.description) + result["displayName"] = from_str(self.display_name) + result["name"] = from_str(self.name) + return result + + +@dataclass +class SessionAgentSelectResult: + agent: SessionAgentSelectResultAgent + """The newly selected custom agent""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentSelectResult': + assert isinstance(obj, dict) + agent = SessionAgentSelectResultAgent.from_dict(obj.get("agent")) + return SessionAgentSelectResult(agent) + + def to_dict(self) -> dict: + result: dict = {} + result["agent"] = to_class(SessionAgentSelectResultAgent, self.agent) + return result + + +@dataclass +class SessionAgentSelectParams: + name: str + """Name of the custom agent to select""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentSelectParams': + assert isinstance(obj, dict) + name = from_str(obj.get("name")) + return SessionAgentSelectParams(name) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + return result + + +@dataclass +class SessionAgentDeselectResult: + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentDeselectResult': + assert isinstance(obj, dict) + return SessionAgentDeselectResult() + + def to_dict(self) -> dict: + result: dict = {} + return result + + +@dataclass +class SessionCompactionCompactResult: + messages_removed: float + """Number of messages removed during compaction""" + + success: bool + """Whether compaction completed successfully""" + + tokens_removed: float + """Number of tokens freed by compaction""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionCompactionCompactResult': + assert isinstance(obj, dict) + messages_removed = from_float(obj.get("messagesRemoved")) + success = from_bool(obj.get("success")) + tokens_removed = from_float(obj.get("tokensRemoved")) + return SessionCompactionCompactResult(messages_removed, success, tokens_removed) + + def to_dict(self) -> dict: + result: dict = {} + result["messagesRemoved"] = to_float(self.messages_removed) + result["success"] = from_bool(self.success) + result["tokensRemoved"] = to_float(self.tokens_removed) + return result + + def ping_result_from_dict(s: Any) -> PingResult: return PingResult.from_dict(s) @@ -908,6 +1098,54 @@ def session_fleet_start_params_to_dict(x: SessionFleetStartParams) -> Any: return to_class(SessionFleetStartParams, x) +def session_agent_list_result_from_dict(s: Any) -> SessionAgentListResult: + return SessionAgentListResult.from_dict(s) + + +def session_agent_list_result_to_dict(x: SessionAgentListResult) -> Any: + return to_class(SessionAgentListResult, x) + + +def session_agent_get_current_result_from_dict(s: Any) -> SessionAgentGetCurrentResult: + return SessionAgentGetCurrentResult.from_dict(s) + + +def session_agent_get_current_result_to_dict(x: SessionAgentGetCurrentResult) -> Any: + return to_class(SessionAgentGetCurrentResult, x) + + +def session_agent_select_result_from_dict(s: Any) -> SessionAgentSelectResult: + return SessionAgentSelectResult.from_dict(s) + + +def session_agent_select_result_to_dict(x: SessionAgentSelectResult) -> Any: + return to_class(SessionAgentSelectResult, x) + + +def session_agent_select_params_from_dict(s: Any) -> SessionAgentSelectParams: + return SessionAgentSelectParams.from_dict(s) + + +def session_agent_select_params_to_dict(x: SessionAgentSelectParams) -> Any: + return to_class(SessionAgentSelectParams, x) + + +def session_agent_deselect_result_from_dict(s: Any) -> SessionAgentDeselectResult: + return SessionAgentDeselectResult.from_dict(s) + + +def session_agent_deselect_result_to_dict(x: SessionAgentDeselectResult) -> Any: + return to_class(SessionAgentDeselectResult, x) + + +def session_compaction_compact_result_from_dict(s: Any) -> SessionCompactionCompactResult: + return SessionCompactionCompactResult.from_dict(s) + + +def session_compaction_compact_result_to_dict(x: SessionCompactionCompactResult) -> Any: + return to_class(SessionCompactionCompactResult, x) + + class ModelsApi: def __init__(self, client: "JsonRpcClient"): self._client = client @@ -1021,6 +1259,35 @@ async def start(self, params: SessionFleetStartParams) -> SessionFleetStartResul return SessionFleetStartResult.from_dict(await self._client.request("session.fleet.start", params_dict)) +class AgentApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def list(self) -> SessionAgentListResult: + return SessionAgentListResult.from_dict(await self._client.request("session.agent.list", {"sessionId": self._session_id})) + + async def get_current(self) -> SessionAgentGetCurrentResult: + return SessionAgentGetCurrentResult.from_dict(await self._client.request("session.agent.getCurrent", {"sessionId": self._session_id})) + + async def select(self, params: SessionAgentSelectParams) -> SessionAgentSelectResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionAgentSelectResult.from_dict(await self._client.request("session.agent.select", params_dict)) + + async def deselect(self) -> SessionAgentDeselectResult: + return SessionAgentDeselectResult.from_dict(await self._client.request("session.agent.deselect", {"sessionId": self._session_id})) + + +class CompactionApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def compact(self) -> SessionCompactionCompactResult: + return SessionCompactionCompactResult.from_dict(await self._client.request("session.compaction.compact", {"sessionId": self._session_id})) + + class SessionRpc: """Typed session-scoped RPC methods.""" def __init__(self, client: "JsonRpcClient", session_id: str): @@ -1031,4 +1298,6 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self.plan = PlanApi(client, session_id) self.workspace = WorkspaceApi(client, session_id) self.fleet = FleetApi(client, session_id) + self.agent = AgentApi(client, session_id) + self.compaction = CompactionApi(client, session_id) diff --git a/python/e2e/test_agent_and_compact_rpc.py b/python/e2e/test_agent_and_compact_rpc.py new file mode 100644 index 000000000..6d7553cbc --- /dev/null +++ b/python/e2e/test_agent_and_compact_rpc.py @@ -0,0 +1,185 @@ +"""E2E tests for Agent Selection and Session Compaction RPC APIs.""" + +import pytest + +from copilot import CopilotClient +from copilot.generated.rpc import SessionAgentSelectParams + +from .testharness import CLI_PATH, E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestAgentSelectionRpc: + @pytest.mark.asyncio + async def test_should_list_available_custom_agents(self): + """Test listing available custom agents via RPC.""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session( + { + "custom_agents": [ + { + "name": "test-agent", + "display_name": "Test Agent", + "description": "A test agent", + "prompt": "You are a test agent.", + }, + { + "name": "another-agent", + "display_name": "Another Agent", + "description": "Another test agent", + "prompt": "You are another agent.", + }, + ] + } + ) + + result = await session.rpc.agent.list() + assert result.agents is not None + assert len(result.agents) == 2 + assert result.agents[0].name == "test-agent" + assert result.agents[0].display_name == "Test Agent" + assert result.agents[0].description == "A test agent" + assert result.agents[1].name == "another-agent" + + await session.destroy() + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_should_return_null_when_no_agent_is_selected(self): + """Test getCurrent returns null when no agent is selected.""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session( + { + "custom_agents": [ + { + "name": "test-agent", + "display_name": "Test Agent", + "description": "A test agent", + "prompt": "You are a test agent.", + } + ] + } + ) + + result = await session.rpc.agent.get_current() + assert result.agent is None + + await session.destroy() + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_should_select_and_get_current_agent(self): + """Test selecting an agent and verifying getCurrent returns it.""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session( + { + "custom_agents": [ + { + "name": "test-agent", + "display_name": "Test Agent", + "description": "A test agent", + "prompt": "You are a test agent.", + } + ] + } + ) + + # Select the agent + select_result = await session.rpc.agent.select( + SessionAgentSelectParams(name="test-agent") + ) + assert select_result.agent is not None + assert select_result.agent.name == "test-agent" + assert select_result.agent.display_name == "Test Agent" + + # Verify getCurrent returns the selected agent + current_result = await session.rpc.agent.get_current() + assert current_result.agent is not None + assert current_result.agent.name == "test-agent" + + await session.destroy() + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_should_deselect_current_agent(self): + """Test deselecting the current agent.""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session( + { + "custom_agents": [ + { + "name": "test-agent", + "display_name": "Test Agent", + "description": "A test agent", + "prompt": "You are a test agent.", + } + ] + } + ) + + # Select then deselect + await session.rpc.agent.select(SessionAgentSelectParams(name="test-agent")) + await session.rpc.agent.deselect() + + # Verify no agent is selected + current_result = await session.rpc.agent.get_current() + assert current_result.agent is None + + await session.destroy() + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_should_return_empty_list_when_no_custom_agents_configured(self): + """Test listing agents returns empty when none configured.""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session({}) + + result = await session.rpc.agent.list() + assert result.agents == [] + + await session.destroy() + await client.stop() + finally: + await client.force_stop() + + +class TestSessionCompactionRpc: + @pytest.mark.asyncio + async def test_should_compact_session_history_after_messages(self, ctx: E2ETestContext): + """Test compacting session history via RPC.""" + session = await ctx.client.create_session({}) + + # Send a message to create some history + await session.send_and_wait({"prompt": "What is 2+2?"}) + + # Compact the session + result = await session.rpc.compaction.compact() + assert isinstance(result.success, bool) + assert isinstance(result.tokens_removed, (int, float)) + assert isinstance(result.messages_removed, (int, float)) + + await session.destroy() diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index e5e0fcf9a..f2e536257 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -467,6 +467,14 @@ function singularPascal(s: string): string { } function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassName: string, propName: string, classes: string[]): string { + // Handle anyOf: [T, null] → T? (nullable typed property) + if (schema.anyOf) { + const hasNull = schema.anyOf.some((s) => typeof s === "object" && (s as JSONSchema7).type === "null"); + const nonNull = schema.anyOf.filter((s) => typeof s === "object" && (s as JSONSchema7).type !== "null"); + if (nonNull.length === 1) { + return resolveRpcType(nonNull[0] as JSONSchema7, isRequired && !hasNull, parentClassName, propName, classes); + } + } // Handle enums (string unions like "interactive" | "plan" | "autopilot") if (schema.enum && Array.isArray(schema.enum)) { const enumName = getOrCreateEnum(parentClassName, propName, schema.enum as string[], rpcEnumOutput); @@ -621,7 +629,7 @@ function emitServerInstanceMethod( let requestClassName: string | null = null; if (paramEntries.length > 0) { - requestClassName = `${methodName}Request`; + requestClassName = `${typeToClassName(method.rpcMethod)}Request`; const reqClass = emitRpcClass(requestClassName, method.params!, "internal", classes); if (reqClass) classes.push(reqClass); } @@ -685,7 +693,7 @@ function emitSessionApiClass(className: string, node: Record, c const paramEntries = (method.params?.properties ? Object.entries(method.params.properties) : []).filter(([k]) => k !== "sessionId"); const requiredSet = new Set(method.params?.required || []); - const requestClassName = `${methodName}Request`; + const requestClassName = `${typeToClassName(method.rpcMethod)}Request`; if (method.params) { const reqClass = emitRpcClass(requestClassName, method.params, "internal", classes); if (reqClass) classes.push(reqClass); diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index d3dab9dc2..5c465ee4c 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -632,6 +632,7 @@ function normalizeUserMessage(content: string): string { return content .replace(/.*?<\/current_datetime>/g, "") .replace(/[\s\S]*?<\/reminder>/g, "") + .replace(/Please create a detailed summary of the conversation so far\. The history is being compacted[\s\S]*/, "${compaction_prompt}") .trim(); } diff --git a/test/snapshots/agent_and_compact_rpc/should_compact_session_history_after_messages.yaml b/test/snapshots/agent_and_compact_rpc/should_compact_session_history_after_messages.yaml new file mode 100644 index 000000000..fa5cf614a --- /dev/null +++ b/test/snapshots/agent_and_compact_rpc/should_compact_session_history_after_messages.yaml @@ -0,0 +1,80 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 2+2? + - role: assistant + content: 2+2 = 4 + - role: user + content: ${compaction_prompt} + - role: assistant + content: >- + + + The user asked a simple arithmetic question ("What is 2+2?") which was answered directly (4). No technical + work, code changes, or file modifications were requested or performed. This appears to be a minimal test or + verification exchange before any substantive work begins. + + + + + + + 1. The user asked "What is 2+2?" + - Provided the answer: 4 + - No follow-up actions were needed + + + + + + No work has been performed. No files were created, modified, or deleted. This was purely an informational + exchange with no code changes or system modifications. + + + Current state: + + - Working directory: ${workdir} + + - Not a git repository + + - No files have been accessed or modified + + - No todos or plan have been created + + + + + + + - Environment is Windows (Windows_NT), requiring backslash path separators + + - Session workspace available for artifacts (e.g., plan.md) + + - SQL database available but not yet initialized (no tables created) + + - Available tools: git, curl, gh + + - No technical work has been performed yet, so no technical details have been discovered + + + + + + + No files have been accessed or modified during this conversation. + + + + + + + No work is currently in progress. Awaiting user's next request for any substantive task. + + + + + Simple arithmetic question answered 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 index a33904cfb..cc7e4ddb6 100644 --- 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 @@ -24,241 +24,14 @@ conversations: - 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. + content: ${compaction_prompt} - 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. + The user requested creative storytelling content (a detailed story about a dragon and its castle), which is + outside the scope of a terminal assistant designed for software engineering tasks. No technical work was + performed, and the user has not yet provided any coding or development-related requests. @@ -266,53 +39,53 @@ conversations: 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 + - I provided a brief story about Theraxis, a dragon guarding ancient knowledge + - I clarified my role as a terminal assistant and offered to help with coding projects instead - 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 + 2. The user asked to continue the story with more details about the dragon's castle + - No action was taken as the compaction request came immediately after - 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 work has been performed. No files have been created, modified, or deleted. No coding tasks have + been attempted. The conversation has consisted entirely of non-technical storytelling requests. - No technical details to report. No coding tasks, debugging, repository work, or software engineering - activities have occurred. + - No technical work has been done + + - No codebase has been explored or modified + + - Environment: Windows (${workdir}) + + - No git repository detected in working directory + + - No SQL tables have been created yet - No files are relevant to this conversation. No files have been accessed, created, or modified. + No files have been worked on or are relevant to the conversation. - 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. + Awaiting a technical or software engineering task from the user. Current requests have been for creative + storytelling, which is outside the assistant's primary purpose. - Non-technical storytelling requests + No technical work performed - messages: - role: system content: ${system} @@ -375,6 +148,12 @@ conversations: + + + Now describe the dragon's treasure in great detail. + + + Here is a summary of the prior context: @@ -382,10 +161,9 @@ conversations: - 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. + The user requested creative storytelling content (a detailed story about a dragon and its castle), which is + outside the scope of a terminal assistant designed for software engineering tasks. No technical work was + performed, and the user has not yet provided any coding or development-related requests. @@ -393,53 +171,53 @@ conversations: 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 + - I provided a brief story about Theraxis, a dragon guarding ancient knowledge + - I clarified my role as a terminal assistant and offered to help with coding projects instead - 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 + 2. The user asked to continue the story with more details about the dragon's castle + - No action was taken as the compaction request came immediately after - 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 work has been performed. No files have been created, modified, or deleted. No coding tasks have + been attempted. The conversation has consisted entirely of non-technical storytelling requests. - No technical details to report. No coding tasks, debugging, repository work, or software engineering - activities have occurred. + - No technical work has been done + + - No codebase has been explored or modified + + - Environment: Windows (${workdir}) + + - No git repository detected in working directory + + - No SQL tables have been created yet - No files are relevant to this conversation. No files have been accessed, created, or modified. + No files have been worked on or are relevant to the conversation. - 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. + Awaiting a technical or software engineering task from the user. Current requests have been for creative + storytelling, which is outside the assistant's primary purpose. - Non-technical storytelling requests + No technical work performed - role: assistant @@ -464,55 +242,36 @@ conversations: 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 understand you're interested in the dragon story, but as I've mentioned, I'm a terminal assistant built for + software engineering tasks—not creative storytelling. - I'm built to help you with: + I'm here to help with things like: - Writing and debugging code - - Managing git repositories + - Managing files and repositories - - Running tests and builds - - - Creating and editing files + - Running builds and tests - Installing dependencies - - And other development tasks - + - Automating tasks with scripts - 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? + If you have any coding projects or technical tasks you need help with, I'd be happy to assist with those + instead! - 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. + Based on the conversation summary, I previously told you a brief story about Theraxis, a dragon who guarded + ancient knowledge. - 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. + However, I should clarify that creative storytelling isn't really what I'm designed for. I'm a terminal + assistant focused on software engineering tasks like coding, testing, file management, and development + workflows. - Is there a software engineering task I can assist you with? + Is there a technical project or coding task I can help you with today?