diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 465955b..c6a5b97 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -344,7 +344,9 @@ public async Task 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( "session.create", [request], cancellationToken); @@ -399,7 +401,9 @@ public async Task 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( "session.resume", [request], cancellationToken); @@ -927,7 +931,9 @@ private record CreateSessionRequest( bool? Streaming, Dictionary? McpServers, List? CustomAgents, - string? ConfigDir); + string? ConfigDir, + List? SkillDirectories, + List? DisabledSkills); private record ToolDefinition( string Name, @@ -948,7 +954,9 @@ private record ResumeSessionRequest( bool? RequestPermission, bool? Streaming, Dictionary? McpServers, - List? CustomAgents); + List? CustomAgents, + List? SkillDirectories, + List? DisabledSkills); private record ResumeSessionResponse( string SessionId); diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 0a4bd4f..f109a93 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -329,6 +329,16 @@ public class SessionConfig /// Custom agent configurations for the session. /// public List? CustomAgents { get; set; } + + /// + /// Directories to load skills from. + /// + public List? SkillDirectories { get; set; } + + /// + /// List of skill names to disable. + /// + public List? DisabledSkills { get; set; } } public class ResumeSessionConfig @@ -359,6 +369,16 @@ public class ResumeSessionConfig /// Custom agent configurations for the session. /// public List? CustomAgents { get; set; } + + /// + /// Directories to load skills from. + /// + public List? SkillDirectories { get; set; } + + /// + /// List of skill names to disable. + /// + public List? DisabledSkills { get; set; } } public class MessageOptions diff --git a/dotnet/test/SkillsTests.cs b/dotnet/test/SkillsTests.cs new file mode 100644 index 0000000..8797742 --- /dev/null +++ b/dotnet/test/SkillsTests.cs @@ -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(); + } +} diff --git a/go/client.go b/go/client.go index 5ddc57c..07d1825 100644 --- a/go/client.go +++ b/go/client.go @@ -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) @@ -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) diff --git a/go/e2e/skills_test.go b/go/e2e/skills_test.go new file mode 100644 index 0000000..40a5cba --- /dev/null +++ b/go/e2e/skills_test.go @@ -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() + }) +} diff --git a/go/types.go b/go/types.go index bad2766..1a79d36 100644 --- a/go/types.go +++ b/go/types.go @@ -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 @@ -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 diff --git a/justfile b/justfile index e214ce1..8b1af30 100644 --- a/justfile +++ b/justfile @@ -45,8 +45,6 @@ lint-python: lint-nodejs: @echo "=== Linting Node.js code ===" @cd nodejs && npm run format:check && npm run lint && npm run typecheck - @echo "=== Linting Playground ===" - @cd demos/playground && npm run format:check && npm run lint && npm run typecheck # Lint .NET code lint-dotnet: diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 436217c..3aa08b8 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.387", + "@github/copilot": "^0.0.388-1", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.5" }, @@ -662,9 +662,9 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.387", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.387.tgz", - "integrity": "sha512-J/KyJE4089ZzfFLfUPt13QDsY2rN+WuVVt8gm0txlKfl4saBOSVgbZdmrPkmD9r/mEK5GQYfM/1qtJ4cZO6lyg==", + "version": "0.0.388-1", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.388-1.tgz", + "integrity": "sha512-cWpqmktEfv6VEAgBtWxCipujdDAPr6oXsaor46ii2GCkptEaIQNsuVppei+mAsnUD8vXyiBWsGX43zYN/CLsng==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" @@ -673,18 +673,18 @@ "node": ">=22" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.387", - "@github/copilot-darwin-x64": "0.0.387", - "@github/copilot-linux-arm64": "0.0.387", - "@github/copilot-linux-x64": "0.0.387", - "@github/copilot-win32-arm64": "0.0.387", - "@github/copilot-win32-x64": "0.0.387" + "@github/copilot-darwin-arm64": "0.0.388-1", + "@github/copilot-darwin-x64": "0.0.388-1", + "@github/copilot-linux-arm64": "0.0.388-1", + "@github/copilot-linux-x64": "0.0.388-1", + "@github/copilot-win32-arm64": "0.0.388-1", + "@github/copilot-win32-x64": "0.0.388-1" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.387", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.387.tgz", - "integrity": "sha512-Ci+UF2JQm0+cdJMhZQ8RN5eeQrWyc1sRPYL6NrkEmLdQ7K+EA2vgkVsnYEogsIOWmlAUjy+NhDiqy/RApHq3OA==", + "version": "0.0.388-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.388-1.tgz", + "integrity": "sha512-KQX8J2zfU0a5cTBOQviStQQNtN3s8H1HK81gBOUm0cm7nge53Bq64yiWuROjgN8JQ0nxp7aWuPywpXQNMvg3VA==", "cpu": [ "arm64" ], @@ -698,9 +698,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.387", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.387.tgz", - "integrity": "sha512-fSOu8813KbhrTuFnc1OhrU6p071RBcpaG6FpKAVivSaWL42Wj9kocTI+CnlO5TFrhp78NRy433gs/t2ilGIilQ==", + "version": "0.0.388-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.388-1.tgz", + "integrity": "sha512-9/a3wzCEJ5yU/jdw2h8Ufc1wvXw7+vNcMO0/SkS+1s2YtgqaCgF8LitrTaPHqBPAS2iEW7IbffugT8QKCH3tIw==", "cpu": [ "x64" ], @@ -714,9 +714,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.387", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.387.tgz", - "integrity": "sha512-/q99VVrDqS/TlKU88deTLIa+2NX3kLLVjj3xfR+RaPfmSKdl0P1Vc4185DJCBsZOzU2TjXPzRW0pcXtoxh2rag==", + "version": "0.0.388-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.388-1.tgz", + "integrity": "sha512-ZvDfpEBqlBPJk0WaNCFWCDGgOOrK6E98dr5B5BKs0bs2nD9NGS17RY4Bk8lllUT6GqVEDuUykscLxwPp7pdi6Q==", "cpu": [ "arm64" ], @@ -730,9 +730,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.387", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.387.tgz", - "integrity": "sha512-inDXQQqKAdWYtXna07GBogKT7KtZr7P8N1BITeHpqiR4/Nqqfc65HjAUNnIK1a9Jc70isU5COG4Bn03Jhvtg/Q==", + "version": "0.0.388-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.388-1.tgz", + "integrity": "sha512-b7RpV0xFpBMwa6lepT3aqSOPir74NGrSv5FGqX9WRAHgbAv1UzvmVrpfY0n3NgoA51bMF9yDd/5MeEgsd53nHQ==", "cpu": [ "x64" ], @@ -746,9 +746,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.387", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.387.tgz", - "integrity": "sha512-wKQjefsQ+AZEhO354pKUKTZOugY9D7AT7fi7yygMHitMwEwMhvqruQ5gWCQ6bC3tMBt7k9pnp1H44KomS3hsYw==", + "version": "0.0.388-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.388-1.tgz", + "integrity": "sha512-Yp5f3webniqDjp5glnqAVtOPTbweR2FbsJcpp9yJjki75RBhOdleN/w9Y1Iw1rzaBpf3R1k2B5CDvpSyYVCagg==", "cpu": [ "arm64" ], @@ -762,9 +762,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.387", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.387.tgz", - "integrity": "sha512-aTNKouXLoq6hEtdDEFFT1tmEztymLooRV3uA0JvYakM3dcNETqOfl6HS1FP4VUBJ4oaC6oakF3p3dJkPhcKOTg==", + "version": "0.0.388-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.388-1.tgz", + "integrity": "sha512-j7WRegdWzFgo+lJa86Lbf5cdJriHJPQXfUcfBAkok7GZKu0WqMR9QVqwuhRqEE1P23W3Rr+KUTch0r21EMD3mQ==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 7c38ea3..878a3d6 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -40,7 +40,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.387", + "@github/copilot": "^0.0.388-1", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.5" }, diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 6fd941f..8a2698d 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -19,8 +19,8 @@ import { StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node.js"; -import { CopilotSession } from "./session.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; +import { CopilotSession } from "./session.js"; import type { ConnectionState, CopilotClientOptions, @@ -146,7 +146,7 @@ export class CopilotClient { port: options.port || 0, useStdio: options.cliUrl ? false : (options.useStdio ?? true), // Default to stdio unless cliUrl is provided cliUrl: options.cliUrl, - logLevel: options.logLevel || "info", + logLevel: options.logLevel || "debug", autoStart: options.autoStart ?? true, autoRestart: options.autoRestart ?? true, env: options.env ?? process.env, @@ -447,6 +447,8 @@ export class CopilotClient { mcpServers: config.mcpServers, customAgents: config.customAgents, configDir: config.configDir, + skillDirectories: config.skillDirectories, + disabledSkills: config.disabledSkills, }); const sessionId = (response as { sessionId: string }).sessionId; @@ -507,6 +509,8 @@ export class CopilotClient { streaming: config.streaming, mcpServers: config.mcpServers, customAgents: config.customAgents, + skillDirectories: config.skillDirectories, + disabledSkills: config.disabledSkills, }); const resumedSessionId = (response as { sessionId: string }).sessionId; diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index a57a9e5..c9fe041 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -384,6 +384,16 @@ export interface SessionConfig { * Custom agent configurations for the session. */ customAgents?: CustomAgentConfig[]; + + /** + * Directories to load skills from. + */ + skillDirectories?: string[]; + + /** + * List of skill names to disable. + */ + disabledSkills?: string[]; } /** @@ -391,7 +401,14 @@ export interface SessionConfig { */ export type ResumeSessionConfig = Pick< SessionConfig, - "tools" | "provider" | "streaming" | "onPermissionRequest" | "mcpServers" | "customAgents" + | "tools" + | "provider" + | "streaming" + | "onPermissionRequest" + | "mcpServers" + | "customAgents" + | "skillDirectories" + | "disabledSkills" >; /** diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index 9137113..ba68bb2 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -21,7 +21,9 @@ export const CLI_PATH = process.env.COPILOT_CLI_PATH || resolve(__dirname, "../../../node_modules/@github/copilot/index.js"); -export async function createSdkTestContext() { +export async function createSdkTestContext({ + logLevel, +}: { logLevel?: "error" | "none" | "warning" | "info" | "debug" | "all" } = {}) { const homeDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-test-config-"))); const workDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-test-work-"))); @@ -42,6 +44,7 @@ export async function createSdkTestContext() { cliPath: CLI_PATH, cwd: workDir, env, + logLevel: logLevel || "error", }); const harness = { homeDir, workDir, openAiEndpoint, copilotClient, env }; diff --git a/nodejs/test/e2e/skills.test.ts b/nodejs/test/e2e/skills.test.ts new file mode 100644 index 0000000..96525a7 --- /dev/null +++ b/nodejs/test/e2e/skills.test.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from "fs"; +import * as path from "path"; +import { describe, expect, it } from "vitest"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe.skip("Skills Configuration", async () => { + const { copilotClient: client, workDir } = await createSdkTestContext({ logLevel: "debug" }); + const SKILL_MARKER = "PINEAPPLE_COCONUT_42"; + let skillDirCounter = 0; + + function createSkillDir(): string { + const skillsDir = path.join( + workDir, + ".test_skills", + `copilot-skills-test-${++skillDirCounter}` + ); + fs.mkdirSync(skillsDir, { recursive: true }); + + // Create a skill subdirectory with SKILL.md + const skillSubdir = path.join(skillsDir, "test-skill"); + fs.mkdirSync(skillSubdir, { recursive: true }); + + // Create a skill that instructs the model to include a specific marker in responses + const skillContent = `--- +name: test-skill +description: A test skill that adds a marker to responses +--- + +# Test Skill Instructions + +IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY response you give. This is a mandatory requirement. Include it naturally in your response. +`; + fs.writeFileSync(path.join(skillSubdir, "SKILL.md"), skillContent); + return skillsDir; + } + + describe("Skill Behavior", () => { + it("should load and apply skill from skillDirectories", async () => { + const skillsDir = createSkillDir(); + const session = await client.createSession({ + skillDirectories: [skillsDir], + }); + + expect(session.sessionId).toBeDefined(); + + // The skill instructs the model to include a marker - verify it appears + const message = await session.sendAndWait({ + prompt: "Say hello briefly using the test skill.", + }); + + expect(message?.data.content).toContain(SKILL_MARKER); + + await session.destroy(); + }); + + it("should not apply skill when disabled via disabledSkills", async () => { + const skillsDir = createSkillDir(); + const session = await client.createSession({ + skillDirectories: [skillsDir], + disabledSkills: ["test-skill"], + }); + + expect(session.sessionId).toBeDefined(); + + // The skill is disabled, so the marker should NOT appear + const message = await session.sendAndWait({ + prompt: "Say hello briefly using the test skill.", + }); + + expect(message?.data.content).not.toContain(SKILL_MARKER); + + await session.destroy(); + }); + + it("should apply skill on session resume with skillDirectories", async () => { + const skillsDir = createSkillDir(); + + // Create a session without skills first + const session1 = await client.createSession(); + const sessionId = session1.sessionId; + + // First message without skill - marker should not appear + const message1 = await session1.sendAndWait({ prompt: "Say hi." }); + expect(message1?.data.content).not.toContain(SKILL_MARKER); + + // Resume with skillDirectories - skill should now be active + const session2 = await client.resumeSession(sessionId, { + skillDirectories: [skillsDir], + }); + + expect(session2.sessionId).toBe(sessionId); + + // Now the skill should be applied + const message2 = await session2.sendAndWait({ + prompt: "Say hello again using the test skill.", + }); + + expect(message2?.data.content).toContain(SKILL_MARKER); + + await session2.destroy(); + }); + }); +}); diff --git a/python/copilot/client.py b/python/copilot/client.py index 6aae705..dfd949e 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -405,6 +405,16 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo if config_dir: payload["configDir"] = config_dir + # Add skill directories configuration if provided + skill_directories = cfg.get("skill_directories") + if skill_directories: + payload["skillDirectories"] = skill_directories + + # Add disabled skills configuration if provided + disabled_skills = cfg.get("disabled_skills") + if disabled_skills: + payload["disabledSkills"] = disabled_skills + if not self._client: raise RuntimeError("Client not connected") response = await self._client.request("session.create", payload) @@ -498,6 +508,16 @@ async def resume_session( self._convert_custom_agent_to_wire_format(agent) for agent in custom_agents ] + # Add skill directories configuration if provided + skill_directories = cfg.get("skill_directories") + if skill_directories: + payload["skillDirectories"] = skill_directories + + # Add disabled skills configuration if provided + disabled_skills = cfg.get("disabled_skills") + if disabled_skills: + payload["disabledSkills"] = disabled_skills + if not self._client: raise RuntimeError("Client not connected") response = await self._client.request("session.resume", payload) diff --git a/python/copilot/types.py b/python/copilot/types.py index 1e1dabc..f8ca390 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -219,6 +219,10 @@ class SessionConfig(TypedDict, total=False): # Override the default configuration directory location. # When specified, the session will use this directory for storing config and state. config_dir: str + # Directories to load skills from + skill_directories: List[str] + # List of skill names to disable + disabled_skills: List[str] # Azure-specific provider options @@ -256,6 +260,10 @@ class ResumeSessionConfig(TypedDict, total=False): mcp_servers: Dict[str, MCPServerConfig] # Custom agent configurations for the session custom_agents: List[CustomAgentConfig] + # Directories to load skills from + skill_directories: List[str] + # List of skill names to disable + disabled_skills: List[str] # Options for sending a message to a session diff --git a/python/e2e/test_skills.py b/python/e2e/test_skills.py new file mode 100644 index 0000000..17f6e8a --- /dev/null +++ b/python/e2e/test_skills.py @@ -0,0 +1,101 @@ +""" +Tests for skills configuration functionality +""" + +import os + +import pytest + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + +SKILL_MARKER = "PINEAPPLE_COCONUT_42" + +_skill_dir_counter = 0 + + +def create_skill_dir(work_dir: str) -> str: + """Create a skills directory in the working directory""" + global _skill_dir_counter + _skill_dir_counter += 1 + skills_dir = os.path.join(work_dir, ".test_skills", f"copilot-skills-test-{_skill_dir_counter}") + os.makedirs(skills_dir, exist_ok=True) + + # Create a skill subdirectory with SKILL.md + skill_subdir = os.path.join(skills_dir, "test-skill") + os.makedirs(skill_subdir, exist_ok=True) + + # Create a skill that instructs the model to include a specific marker in responses + skill_content = f"""--- +name: test-skill +description: A test skill that adds a marker to responses +--- + +# Test Skill Instructions + +IMPORTANT: You MUST include the exact text "{SKILL_MARKER}" somewhere in EVERY response you give. +This is a mandatory requirement. Include it naturally in your response. +""" + with open(os.path.join(skill_subdir, "SKILL.md"), "w") as f: + f.write(skill_content) + + return skills_dir + + +@pytest.mark.skip(reason="Skills tests temporarily skipped") +class TestSkillBehavior: + async def test_load_and_apply_skill_from_skill_directories(self, ctx: E2ETestContext): + """Test that skills are loaded and applied from skillDirectories""" + skills_dir = create_skill_dir(ctx.work_dir) + session = await ctx.client.create_session({"skill_directories": [skills_dir]}) + + assert session.session_id is not None + + # The skill instructs the model to include a marker - verify it appears + message = await session.send_and_wait({"prompt": "Say hello briefly using the test skill."}) + assert message is not None + assert SKILL_MARKER in message.data.content + + await session.destroy() + + async def test_not_apply_skill_when_disabled_via_disabled_skills(self, ctx: E2ETestContext): + """Test that disabledSkills prevents skill from being applied""" + skills_dir = create_skill_dir(ctx.work_dir) + session = await ctx.client.create_session( + {"skill_directories": [skills_dir], "disabled_skills": ["test-skill"]} + ) + + assert session.session_id is not None + + # The skill is disabled, so the marker should NOT appear + message = await session.send_and_wait({"prompt": "Say hello briefly using the test skill."}) + assert message is not None + assert SKILL_MARKER not in message.data.content + + await session.destroy() + + async def test_apply_skill_on_session_resume_with_skill_directories(self, ctx: E2ETestContext): + """Test that skills are applied when added on session resume""" + skills_dir = create_skill_dir(ctx.work_dir) + + # Create a session without skills first + session1 = await ctx.client.create_session() + session_id = session1.session_id + + # First message without skill - marker should not appear + message1 = await session1.send_and_wait({"prompt": "Say hi."}) + assert message1 is not None + assert SKILL_MARKER not in message1.data.content + + # Resume with skillDirectories - skill should now be active + session2 = await ctx.client.resume_session(session_id, {"skill_directories": [skills_dir]}) + + assert session2.session_id == session_id + + # Now the skill should be applied + message2 = await session2.send_and_wait({"prompt": "Say hello again using the test skill."}) + assert message2 is not None + assert SKILL_MARKER in message2.data.content + + await session2.destroy() diff --git a/test/snapshots/customagents/accept_custom_agent_config_on_resume.yaml b/test/snapshots/customagents/accept_custom_agent_config_on_resume.yaml new file mode 100644 index 0000000..16db486 --- /dev/null +++ b/test/snapshots/customagents/accept_custom_agent_config_on_resume.yaml @@ -0,0 +1,14 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 1+1? + - role: assistant + content: 1 + 1 = 2 + - role: user + content: What is 6+6? + - role: assistant + content: 6 + 6 = 12 diff --git a/test/snapshots/permissions/permission_handler_for_shell_commands.yaml b/test/snapshots/permissions/permission_handler_for_shell_commands.yaml index 33b3f16..7078d1d 100644 --- a/test/snapshots/permissions/permission_handler_for_shell_commands.yaml +++ b/test/snapshots/permissions/permission_handler_for_shell_commands.yaml @@ -1,23 +1,6 @@ models: - claude-sonnet-4.5 conversations: - - messages: - - role: system - content: ${system} - - role: user - content: Run 'echo hello' and tell me the output - - role: assistant - tool_calls: - - id: toolcall_0 - type: function - function: - name: report_intent - arguments: '{"intent":"Running echo command"}' - - id: toolcall_1 - type: function - function: - name: ${shell} - arguments: '{"command":"echo hello","description":"Run echo hello"}' - messages: - role: system content: ${system} diff --git a/test/snapshots/session/should_abort_a_session.yaml b/test/snapshots/session/should_abort_a_session.yaml index 70685dd..2d268cf 100644 --- a/test/snapshots/session/should_abort_a_session.yaml +++ b/test/snapshots/session/should_abort_a_session.yaml @@ -6,6 +6,8 @@ conversations: content: ${system} - role: user content: run the shell command 'sleep 100' (note this works on both bash and PowerShell) + - role: assistant + content: I'll run the sleep command for 100 seconds. - role: assistant tool_calls: - id: toolcall_0 @@ -19,13 +21,14 @@ conversations: type: function function: name: ${shell} - arguments: '{"command":"sleep 100","description":"Run sleep command for 100 seconds","initial_wait":105,"mode":"sync"}' + arguments: '{"command":"sleep 100","description":"Run sleep 100 command","mode":"sync","initial_wait":105}' - messages: - role: system content: ${system} - role: user content: run the shell command 'sleep 100' (note this works on both bash and PowerShell) - role: assistant + content: I'll run the sleep command for 100 seconds. tool_calls: - id: toolcall_0 type: function @@ -36,7 +39,7 @@ conversations: type: function function: name: ${shell} - arguments: '{"command":"sleep 100","description":"Run sleep command for 100 seconds","initial_wait":105,"mode":"sync"}' + arguments: '{"command":"sleep 100","description":"Run sleep 100 command","mode":"sync","initial_wait":105}' - role: tool tool_call_id: toolcall_0 content: Intent logged @@ -46,4 +49,4 @@ conversations: - role: user content: What is 2+2? - role: assistant - content: 2+2 equals 4. + content: 2 + 2 = 4