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
16 changes: 12 additions & 4 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,9 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig? config = nul
config?.Streaming == true ? true : null,
config?.McpServers,
config?.CustomAgents,
config?.ConfigDir);
config?.ConfigDir,
config?.SkillDirectories,
config?.DisabledSkills);

var response = await connection.Rpc.InvokeWithCancellationAsync<CreateSessionResponse>(
"session.create", [request], cancellationToken);
Expand Down Expand Up @@ -399,7 +401,9 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
config?.OnPermissionRequest != null ? true : null,
config?.Streaming == true ? true : null,
config?.McpServers,
config?.CustomAgents);
config?.CustomAgents,
config?.SkillDirectories,
config?.DisabledSkills);

var response = await connection.Rpc.InvokeWithCancellationAsync<ResumeSessionResponse>(
"session.resume", [request], cancellationToken);
Expand Down Expand Up @@ -927,7 +931,9 @@ private record CreateSessionRequest(
bool? Streaming,
Dictionary<string, object>? McpServers,
List<CustomAgentConfig>? CustomAgents,
string? ConfigDir);
string? ConfigDir,
List<string>? SkillDirectories,
List<string>? DisabledSkills);

private record ToolDefinition(
string Name,
Expand All @@ -948,7 +954,9 @@ private record ResumeSessionRequest(
bool? RequestPermission,
bool? Streaming,
Dictionary<string, object>? McpServers,
List<CustomAgentConfig>? CustomAgents);
List<CustomAgentConfig>? CustomAgents,
List<string>? SkillDirectories,
List<string>? DisabledSkills);

private record ResumeSessionResponse(
string SessionId);
Expand Down
20 changes: 20 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,16 @@ public class SessionConfig
/// Custom agent configurations for the session.
/// </summary>
public List<CustomAgentConfig>? CustomAgents { get; set; }

/// <summary>
/// Directories to load skills from.
/// </summary>
public List<string>? SkillDirectories { get; set; }

/// <summary>
/// List of skill names to disable.
/// </summary>
public List<string>? DisabledSkills { get; set; }
}

public class ResumeSessionConfig
Expand Down Expand Up @@ -359,6 +369,16 @@ public class ResumeSessionConfig
/// Custom agent configurations for the session.
/// </summary>
public List<CustomAgentConfig>? CustomAgents { get; set; }

/// <summary>
/// Directories to load skills from.
/// </summary>
public List<string>? SkillDirectories { get; set; }

/// <summary>
/// List of skill names to disable.
/// </summary>
public List<string>? DisabledSkills { get; set; }
}

public class MessageOptions
Expand Down
115 changes: 115 additions & 0 deletions dotnet/test/SkillsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

using GitHub.Copilot.SDK.Test.Harness;
using Xunit;
using Xunit.Abstractions;

namespace GitHub.Copilot.SDK.Test;

public class SkillsTests : E2ETestBase
{
private const string SkillMarker = "PINEAPPLE_COCONUT_42";
private static int _skillDirCounter = 0;

private readonly string _workDir;

public SkillsTests(E2ETestFixture fixture, ITestOutputHelper output) : base(fixture, "skills", output)
{
_workDir = fixture.Ctx.WorkDir;
}

private string CreateSkillDir()
{
var skillsDir = Path.Join(_workDir, ".test_skills", $"copilot-skills-test-{++_skillDirCounter}");
Directory.CreateDirectory(skillsDir);

// Create a skill subdirectory with SKILL.md
var skillSubdir = Path.Join(skillsDir, "test-skill");
Directory.CreateDirectory(skillSubdir);

// Create a skill that instructs the model to include a specific marker in responses
var skillContent = $@"---
name: test-skill
description: A test skill that adds a marker to responses
---

# Test Skill Instructions

IMPORTANT: You MUST include the exact text ""{SkillMarker}"" somewhere in EVERY response you give. This is a mandatory requirement. Include it naturally in your response.
".ReplaceLineEndings("\n");
File.WriteAllText(Path.Join(skillSubdir, "SKILL.md"), skillContent);

return skillsDir;
}

[Fact(Skip = "Skills tests temporarily skipped")]
public async Task Should_Load_And_Apply_Skill_From_SkillDirectories()
{
var skillsDir = CreateSkillDir();
var session = await Client.CreateSessionAsync(new SessionConfig
{
SkillDirectories = [skillsDir]
});

Assert.Matches(@"^[a-f0-9-]+$", session.SessionId);

// The skill instructs the model to include a marker - verify it appears
var message = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello briefly using the test skill." });
Assert.NotNull(message);
Assert.Contains(SkillMarker, message!.Data.Content);

await session.DisposeAsync();
}

[Fact(Skip = "Skills tests temporarily skipped")]
public async Task Should_Not_Apply_Skill_When_Disabled_Via_DisabledSkills()
{
var skillsDir = CreateSkillDir();
var session = await Client.CreateSessionAsync(new SessionConfig
{
SkillDirectories = [skillsDir],
DisabledSkills = ["test-skill"]
});

Assert.Matches(@"^[a-f0-9-]+$", session.SessionId);

// The skill is disabled, so the marker should NOT appear
var message = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello briefly using the test skill." });
Assert.NotNull(message);
Assert.DoesNotContain(SkillMarker, message!.Data.Content);

await session.DisposeAsync();
}

[Fact(Skip = "Skills tests temporarily skipped")]
public async Task Should_Apply_Skill_On_Session_Resume_With_SkillDirectories()
{
var skillsDir = CreateSkillDir();

// Create a session without skills first
var session1 = await Client.CreateSessionAsync();
var sessionId = session1.SessionId;

// First message without skill - marker should not appear
var message1 = await session1.SendAndWaitAsync(new MessageOptions { Prompt = "Say hi." });
Assert.NotNull(message1);
Assert.DoesNotContain(SkillMarker, message1!.Data.Content);

// Resume with skillDirectories - skill should now be active
var session2 = await Client.ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
SkillDirectories = [skillsDir]
});

Assert.Equal(sessionId, session2.SessionId);

// Now the skill should be applied
var message2 = await session2.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello again using the test skill." });
Assert.NotNull(message2);
Assert.Contains(SkillMarker, message2!.Data.Content);

await session2.DisposeAsync();
}
}
16 changes: 16 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,14 @@ func (c *Client) CreateSession(config *SessionConfig) (*Session, error) {
if config.ConfigDir != "" {
params["configDir"] = config.ConfigDir
}
// Add skill directories configuration
if len(config.SkillDirectories) > 0 {
params["skillDirectories"] = config.SkillDirectories
}
// Add disabled skills configuration
if len(config.DisabledSkills) > 0 {
params["disabledSkills"] = config.DisabledSkills
}
}

result, err := c.client.Request("session.create", params)
Expand Down Expand Up @@ -664,6 +672,14 @@ func (c *Client) ResumeSessionWithOptions(sessionID string, config *ResumeSessio
}
params["customAgents"] = customAgents
}
// Add skill directories configuration
if len(config.SkillDirectories) > 0 {
params["skillDirectories"] = config.SkillDirectories
}
// Add disabled skills configuration
if len(config.DisabledSkills) > 0 {
params["disabledSkills"] = config.DisabledSkills
}
}

result, err := c.client.Request("session.resume", params)
Expand Down
151 changes: 151 additions & 0 deletions go/e2e/skills_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package e2e

import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"

copilot "github.com/github/copilot-sdk/go"
"github.com/github/copilot-sdk/go/e2e/testharness"
)

const skillMarker = "PINEAPPLE_COCONUT_42"

var skillDirCounter = 0

func createTestSkillDir(t *testing.T, workDir string, marker string) string {
skillDirCounter++
skillsDir := filepath.Join(workDir, ".test_skills", fmt.Sprintf("copilot-skills-test-%d", skillDirCounter))
if err := os.MkdirAll(skillsDir, 0755); err != nil {
t.Fatalf("Failed to create skills directory: %v", err)
}

skillSubdir := filepath.Join(skillsDir, "test-skill")
if err := os.MkdirAll(skillSubdir, 0755); err != nil {
t.Fatalf("Failed to create skill subdirectory: %v", err)
}

skillContent := `---
name: test-skill
description: A test skill that adds a marker to responses
---

# Test Skill Instructions

IMPORTANT: You MUST include the exact text "` + marker + `" somewhere in EVERY response you give. This is a mandatory requirement. Include it naturally in your response.
`
if err := os.WriteFile(filepath.Join(skillSubdir, "SKILL.md"), []byte(skillContent), 0644); err != nil {
t.Fatalf("Failed to write SKILL.md: %v", err)
}

return skillsDir
}

func TestSkillBehavior(t *testing.T) {
t.Skip("Skills tests temporarily skipped")
ctx := testharness.NewTestContext(t)
client := ctx.NewClient()
t.Cleanup(func() { client.ForceStop() })

t.Run("load and apply skill from skillDirectories", func(t *testing.T) {
ctx.ConfigureForTest(t)
skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker)

session, err := client.CreateSession(&copilot.SessionConfig{
SkillDirectories: []string{skillsDir},
})
if err != nil {
t.Fatalf("Failed to create session: %v", err)
}

// The skill instructs the model to include a marker - verify it appears
message, err := session.SendAndWait(copilot.MessageOptions{
Prompt: "Say hello briefly using the test skill.",
}, 60*time.Second)
if err != nil {
t.Fatalf("Failed to send message: %v", err)
}

if message.Data.Content == nil || !strings.Contains(*message.Data.Content, skillMarker) {
t.Errorf("Expected message to contain skill marker '%s', got: %v", skillMarker, message.Data.Content)
}

session.Destroy()
})

t.Run("not apply skill when disabled via disabledSkills", func(t *testing.T) {
ctx.ConfigureForTest(t)
skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker)

session, err := client.CreateSession(&copilot.SessionConfig{
SkillDirectories: []string{skillsDir},
DisabledSkills: []string{"test-skill"},
})
if err != nil {
t.Fatalf("Failed to create session: %v", err)
}

// The skill is disabled, so the marker should NOT appear
message, err := session.SendAndWait(copilot.MessageOptions{
Prompt: "Say hello briefly using the test skill.",
}, 60*time.Second)
if err != nil {
t.Fatalf("Failed to send message: %v", err)
}

if message.Data.Content != nil && strings.Contains(*message.Data.Content, skillMarker) {
t.Errorf("Expected message to NOT contain skill marker '%s' when disabled, got: %v", skillMarker, *message.Data.Content)
}

session.Destroy()
})

t.Run("apply skill on session resume with skillDirectories", func(t *testing.T) {
ctx.ConfigureForTest(t)
skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker)

// Create a session without skills first
session1, err := client.CreateSession(nil)
if err != nil {
t.Fatalf("Failed to create session: %v", err)
}
sessionID := session1.SessionID

// First message without skill - marker should not appear
message1, err := session1.SendAndWait(copilot.MessageOptions{Prompt: "Say hi."}, 60*time.Second)
if err != nil {
t.Fatalf("Failed to send message: %v", err)
}

if message1.Data.Content != nil && strings.Contains(*message1.Data.Content, skillMarker) {
t.Errorf("Expected message to NOT contain skill marker before skill was added, got: %v", *message1.Data.Content)
}

// Resume with skillDirectories - skill should now be active
session2, err := client.ResumeSessionWithOptions(sessionID, &copilot.ResumeSessionConfig{
SkillDirectories: []string{skillsDir},
})
if err != nil {
t.Fatalf("Failed to resume session: %v", err)
}

if session2.SessionID != sessionID {
t.Errorf("Expected session ID %s, got %s", sessionID, session2.SessionID)
}

// Now the skill should be applied
message2, err := session2.SendAndWait(copilot.MessageOptions{Prompt: "Say hello again using the test skill."}, 60*time.Second)
if err != nil {
t.Fatalf("Failed to send message: %v", err)
}

if message2.Data.Content == nil || !strings.Contains(*message2.Data.Content, skillMarker) {
t.Errorf("Expected message to contain skill marker '%s' after resume, got: %v", skillMarker, message2.Data.Content)
}

session2.Destroy()
})
}
8 changes: 8 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ type SessionConfig struct {
MCPServers map[string]MCPServerConfig
// CustomAgents configures custom agents for the session
CustomAgents []CustomAgentConfig
// SkillDirectories is a list of directories to load skills from
SkillDirectories []string
// DisabledSkills is a list of skill names to disable
DisabledSkills []string
}

// Tool describes a caller-implemented tool that can be invoked by Copilot
Expand Down Expand Up @@ -211,6 +215,10 @@ type ResumeSessionConfig struct {
MCPServers map[string]MCPServerConfig
// CustomAgents configures custom agents for the session
CustomAgents []CustomAgentConfig
// SkillDirectories is a list of directories to load skills from
SkillDirectories []string
// DisabledSkills is a list of skill names to disable
DisabledSkills []string
}

// ProviderConfig configures a custom model provider
Expand Down
Loading
Loading