From 5a47cfa5d4b216a21bda3257c753dab29dc31c21 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 2 Apr 2026 14:00:12 -0700 Subject: [PATCH 1/8] feat: add per-agent skills support to SDK types and docs (#958) Add a 'skills' field to CustomAgentConfig across all four SDK languages (Node.js, Python, Go, .NET) to support scoping skills to individual subagents. Skills are opt-in: agents get no skills by default. Changes: - Add skills?: string[] to CustomAgentConfig in all SDKs - Update custom-agents.md with skills in config table and new section - Update skills.md with per-agent skills example and opt-in note - Update streaming-events.md with agentName on skill.invoked event - Add E2E tests for agent-scoped skills in all four SDKs - Add snapshot YAML files for new test scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/features/custom-agents.md | 28 +++++++ docs/features/skills.md | 7 +- docs/features/streaming-events.md | 1 + dotnet/src/Types.cs | 9 +++ dotnet/test/SkillsTests.cs | 61 +++++++++++++++ go/internal/e2e/skills_test.go | 75 +++++++++++++++++++ go/types.go | 2 + nodejs/package-lock.json | 8 ++ nodejs/src/types.ts | 7 ++ nodejs/test/e2e/skills.test.ts | 58 ++++++++++++++ python/copilot/session.py | 2 + python/e2e/test_skills.py | 57 +++++++++++++- ...low_agent_with_skills_to_invoke_skill.yaml | 44 +++++++++++ ..._skills_to_agent_without_skills_field.yaml | 10 +++ 14 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.yaml create mode 100644 test/snapshots/skills/should_not_provide_skills_to_agent_without_skills_field.yaml diff --git a/docs/features/custom-agents.md b/docs/features/custom-agents.md index 6c6455a02..b6cb6e96b 100644 --- a/docs/features/custom-agents.md +++ b/docs/features/custom-agents.md @@ -252,6 +252,7 @@ try (var client = new CopilotClient()) { | `prompt` | `string` | ✅ | System prompt for the agent | | `mcpServers` | `object` | | MCP server configurations specific to this agent | | `infer` | `boolean` | | Whether the runtime can auto-select this agent (default: `true`) | +| `skills` | `string[]` | | List of skill names available to this agent | > **Tip:** A good `description` helps the runtime match user intent to the right agent. Be specific about the agent's expertise and capabilities. @@ -261,6 +262,33 @@ In addition to per-agent configuration above, you can set `agent` on the **sessi |-------------------------|------|-------------| | `agent` | `string` | Name of the custom agent to pre-select at session creation. Must match a `name` in `customAgents`. | +## Per-Agent Skills + +You can scope skills to individual agents using the `skills` property. Skills are **opt-in** — agents have no access to skills by default. The skill names listed in `skills` are resolved from the session-level `skillDirectories`. + +```typescript +const session = await client.createSession({ + skillDirectories: ["./skills"], + customAgents: [ + { + name: "security-auditor", + description: "Security-focused code reviewer", + prompt: "Focus on OWASP Top 10 vulnerabilities", + skills: ["security-scan", "dependency-check"], + }, + { + name: "docs-writer", + description: "Technical documentation writer", + prompt: "Write clear, concise documentation", + skills: ["markdown-lint"], + }, + ], + onPermissionRequest: async () => ({ kind: "approved" }), +}); +``` + +In this example, `security-auditor` can invoke only `security-scan` and `dependency-check`, while `docs-writer` can invoke only `markdown-lint`. An agent without a `skills` field has no access to any skills. + ## Selecting an Agent at Session Creation You can pass `agent` in the session config to pre-select which custom agent should be active when the session starts. The value must match the `name` of one of the agents defined in `customAgents`. diff --git a/docs/features/skills.md b/docs/features/skills.md index 882580fd4..8f1e90dc3 100644 --- a/docs/features/skills.md +++ b/docs/features/skills.md @@ -364,20 +364,23 @@ The markdown body contains the instructions that are injected into the session c ### Skills + Custom Agents -Skills work alongside custom agents: +Skills can be scoped to individual custom agents using the `skills` property. Skills are **opt-in** — agents get no skills by default. Skill names are resolved from the session-level `skillDirectories`. ```typescript const session = await client.createSession({ - skillDirectories: ["./skills/security"], + skillDirectories: ["./skills"], customAgents: [{ name: "security-auditor", description: "Security-focused code reviewer", prompt: "Focus on OWASP Top 10 vulnerabilities", + skills: ["security-scan", "dependency-check"], }], onPermissionRequest: async () => ({ kind: "approved" }), }); ``` +> **Note:** When `skills` is omitted, the agent has **no** access to skills. This is an opt-in model — you must explicitly list the skills each agent needs. + ### Skills + MCP Servers Skills can complement MCP server capabilities: diff --git a/docs/features/streaming-events.md b/docs/features/streaming-events.md index 9dde8f21b..5fd903ef7 100644 --- a/docs/features/streaming-events.md +++ b/docs/features/streaming-events.md @@ -633,6 +633,7 @@ A skill was activated for the current conversation. | `allowedTools` | `string[]` | | Tools auto-approved while this skill is active | | `pluginName` | `string` | | Plugin the skill originated from | | `pluginVersion` | `string` | | Plugin version | +| `agentName` | `string` | | Name of the agent that invoked the skill, when invoked by a custom agent | --- diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 970d44f76..ce5be2395 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1638,6 +1638,15 @@ public class CustomAgentConfig /// [JsonPropertyName("infer")] public bool? Infer { get; set; } + + /// + /// List of skill names available to this agent. + /// Skills are resolved by name from the session's loaded skill pool (configured via skillDirectories). + /// When set, only the listed skills can be invoked by this agent. + /// When omitted, the agent has no access to skills (opt-in model). + /// + [JsonPropertyName("skills")] + public List? Skills { get; set; } } /// diff --git a/dotnet/test/SkillsTests.cs b/dotnet/test/SkillsTests.cs index d68eed79d..4f1baed99 100644 --- a/dotnet/test/SkillsTests.cs +++ b/dotnet/test/SkillsTests.cs @@ -87,6 +87,67 @@ public async Task Should_Not_Apply_Skill_When_Disabled_Via_DisabledSkills() await session.DisposeAsync(); } + [Fact] + public async Task Should_Allow_Agent_With_Skills_To_Invoke_Skill() + { + var skillsDir = CreateSkillDir(); + var customAgents = new List + { + new CustomAgentConfig + { + Name = "skill-agent", + Description = "An agent with access to test-skill", + Prompt = "You are a helpful test agent.", + Skills = ["test-skill"] + } + }; + + var session = await CreateSessionAsync(new SessionConfig + { + SkillDirectories = [skillsDir], + CustomAgents = customAgents + }); + + Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); + + // The agent has Skills = ["test-skill"], so it should be able to invoke the skill + 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] + public async Task Should_Not_Provide_Skills_To_Agent_Without_Skills_Field() + { + var skillsDir = CreateSkillDir(); + var customAgents = new List + { + new CustomAgentConfig + { + Name = "no-skill-agent", + Description = "An agent without skills access", + Prompt = "You are a helpful test agent." + } + }; + + var session = await CreateSessionAsync(new SessionConfig + { + SkillDirectories = [skillsDir], + CustomAgents = customAgents + }); + + Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); + + // The agent has no Skills field, so it should NOT have access to skills + 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 = "See the big comment around the equivalent test in the Node SDK. Skipped because the feature doesn't work correctly yet.")] public async Task Should_Apply_Skill_On_Session_Resume_With_SkillDirectories() { diff --git a/go/internal/e2e/skills_test.go b/go/internal/e2e/skills_test.go index f6943fef9..1a392d0c2 100644 --- a/go/internal/e2e/skills_test.go +++ b/go/internal/e2e/skills_test.go @@ -108,6 +108,81 @@ func TestSkills(t *testing.T) { session.Disconnect() }) + t.Run("should allow agent with skills to invoke skill", func(t *testing.T) { + ctx.ConfigureForTest(t) + cleanSkillsDir(t, ctx.WorkDir) + skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker) + + customAgents := []copilot.CustomAgentConfig{ + { + Name: "skill-agent", + Description: "An agent with access to test-skill", + Prompt: "You are a helpful test agent.", + Skills: []string{"test-skill"}, + }, + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SkillDirectories: []string{skillsDir}, + CustomAgents: customAgents, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // The agent has Skills: ["test-skill"], so it should be able to invoke the skill + message, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Say hello briefly using the test skill.", + }) + 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.Disconnect() + }) + + t.Run("should not provide skills to agent without skills field", func(t *testing.T) { + ctx.ConfigureForTest(t) + cleanSkillsDir(t, ctx.WorkDir) + skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker) + + customAgents := []copilot.CustomAgentConfig{ + { + Name: "no-skill-agent", + Description: "An agent without skills access", + Prompt: "You are a helpful test agent.", + }, + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SkillDirectories: []string{skillsDir}, + CustomAgents: customAgents, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // The agent has no Skills field, so it should NOT have access to skills + message, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Say hello briefly using the test skill.", + }) + 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 agent has no skills, got: %v", skillMarker, *message.Data.Content) + } + + session.Disconnect() + }) + t.Run("should apply skill on session resume with skillDirectories", func(t *testing.T) { t.Skip("See the big comment around the equivalent test in the Node SDK. Skipped because the feature doesn't work correctly yet.") ctx.ConfigureForTest(t) diff --git a/go/types.go b/go/types.go index 568bcc1b9..f9aa2de8d 100644 --- a/go/types.go +++ b/go/types.go @@ -450,6 +450,8 @@ type CustomAgentConfig struct { MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` // Infer indicates whether the agent should be available for model inference Infer *bool `json:"infer,omitempty"` + // Skills is the list of skill names available to this agent (opt-in; omit for no skills) + Skills []string `json:"skills,omitempty"` } // InfiniteSessionConfig configures infinite sessions with automatic context compaction diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 55c3a4f24..236e89219 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1260,6 +1260,7 @@ "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1299,6 +1300,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -1627,6 +1629,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1954,6 +1957,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2862,6 +2866,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3280,6 +3285,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -3313,6 +3319,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3380,6 +3387,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index cb8dd7ad2..a5d1690bd 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1106,6 +1106,13 @@ export interface CustomAgentConfig { * @default true */ infer?: boolean; + /** + * List of skill names available to this agent. + * Skills are resolved by name from the session's loaded skill pool (configured via `skillDirectories`). + * When set, only the listed skills can be invoked by this agent. + * When omitted, the agent has no access to skills (opt-in model). + */ + skills?: string[]; } /** diff --git a/nodejs/test/e2e/skills.test.ts b/nodejs/test/e2e/skills.test.ts index a2173648f..6efc1a64d 100644 --- a/nodejs/test/e2e/skills.test.ts +++ b/nodejs/test/e2e/skills.test.ts @@ -5,6 +5,7 @@ import * as fs from "fs"; import * as path from "path"; import { beforeEach, describe, expect, it } from "vitest"; +import type { CustomAgentConfig } from "../../src/index.js"; import { approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; @@ -92,6 +93,63 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY // Also, if this test runs FIRST and then the "should load and apply skill from skillDirectories" test runs second // within the same run (i.e., sharing the same Client instance), then the second test fails too. There's definitely // some state being shared or cached incorrectly. + it("should allow agent with skills to invoke skill", async () => { + const skillsDir = createSkillDir(); + const customAgents: CustomAgentConfig[] = [ + { + name: "skill-agent", + description: "An agent with access to test-skill", + prompt: "You are a helpful test agent.", + skills: ["test-skill"], + }, + ]; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + skillDirectories: [skillsDir], + customAgents, + }); + + expect(session.sessionId).toBeDefined(); + + // The agent has skills: ["test-skill"], so it should be able to invoke the skill + const message = await session.sendAndWait({ + prompt: "Say hello briefly using the test skill.", + }); + + expect(message?.data.content).toContain(SKILL_MARKER); + + await session.disconnect(); + }); + + it("should not provide skills to agent without skills field", async () => { + const skillsDir = createSkillDir(); + const customAgents: CustomAgentConfig[] = [ + { + name: "no-skill-agent", + description: "An agent without skills access", + prompt: "You are a helpful test agent.", + }, + ]; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + skillDirectories: [skillsDir], + customAgents, + }); + + expect(session.sessionId).toBeDefined(); + + // The agent has no skills field, so it should NOT have access to skills + const message = await session.sendAndWait({ + prompt: "Say hello briefly using the test skill.", + }); + + expect(message?.data.content).not.toContain(SKILL_MARKER); + + await session.disconnect(); + }); + it.skip("should apply skill on session resume with skillDirectories", async () => { const skillsDir = createSkillDir(); diff --git a/python/copilot/session.py b/python/copilot/session.py index 45e8826b7..5f681e503 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -766,6 +766,8 @@ class CustomAgentConfig(TypedDict, total=False): # MCP servers specific to agent mcp_servers: NotRequired[dict[str, MCPServerConfig]] infer: NotRequired[bool] # Whether agent is available for model inference + # List of skill names available to this agent (opt-in; omit for no skills) + skills: NotRequired[list[str]] class InfiniteSessionConfig(TypedDict, total=False): diff --git a/python/e2e/test_skills.py b/python/e2e/test_skills.py index feacae73b..d264d8bf8 100644 --- a/python/e2e/test_skills.py +++ b/python/e2e/test_skills.py @@ -7,7 +7,7 @@ import pytest -from copilot.session import PermissionHandler +from copilot.session import CustomAgentConfig, PermissionHandler from .testharness import E2ETestContext @@ -88,6 +88,61 @@ async def test_should_not_apply_skill_when_disabled_via_disabledskills( await session.disconnect() + async def test_should_allow_agent_with_skills_to_invoke_skill(self, ctx: E2ETestContext): + """Test that an agent with skills can invoke the specified skill""" + skills_dir = create_skill_dir(ctx.work_dir) + custom_agents: list[CustomAgentConfig] = [ + { + "name": "skill-agent", + "description": "An agent with access to test-skill", + "prompt": "You are a helpful test agent.", + "skills": ["test-skill"], + } + ] + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + skill_directories=[skills_dir], + custom_agents=custom_agents, + ) + + assert session.session_id is not None + + # The agent has skills: ["test-skill"], so it should be able to invoke the skill + message = await session.send_and_wait("Say hello briefly using the test skill.") + assert message is not None + assert SKILL_MARKER in message.data.content + + await session.disconnect() + + async def test_should_not_provide_skills_to_agent_without_skills_field( + self, ctx: E2ETestContext + ): + """Test that an agent without skills field gets no skills (opt-in model)""" + skills_dir = create_skill_dir(ctx.work_dir) + custom_agents: list[CustomAgentConfig] = [ + { + "name": "no-skill-agent", + "description": "An agent without skills access", + "prompt": "You are a helpful test agent.", + } + ] + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + skill_directories=[skills_dir], + custom_agents=custom_agents, + ) + + assert session.session_id is not None + + # The agent has no skills field, so it should NOT have access to skills + message = await session.send_and_wait("Say hello briefly using the test skill.") + assert message is not None + assert SKILL_MARKER not in message.data.content + + await session.disconnect() + @pytest.mark.skip( reason="See the big comment around the equivalent test in the Node SDK. " "Skipped because the feature doesn't work correctly yet." diff --git a/test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.yaml b/test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.yaml new file mode 100644 index 000000000..7d364fcbd --- /dev/null +++ b/test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.yaml @@ -0,0 +1,44 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hello briefly using the test skill. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: skill + arguments: '{"skill":"test-skill"}' + - role: tool + tool_call_id: toolcall_0 + content: Skill "test-skill" loaded successfully. Follow the instructions in the skill context. + - role: user + content: >- + + + Base directory for this skill: ${workdir}/.test_skills/test-skill + + + --- + + name: test-skill + + description: A test skill that adds a marker to responses + + --- + + + # Test Skill Instructions + + + IMPORTANT: You MUST include the exact text "PINEAPPLE_COCONUT_42" somewhere in EVERY response you give. This + is a mandatory requirement. Include it naturally in your response. + + + + - role: assistant + content: Hello! PINEAPPLE_COCONUT_42 - I'm ready to help you with your tasks today. diff --git a/test/snapshots/skills/should_not_provide_skills_to_agent_without_skills_field.yaml b/test/snapshots/skills/should_not_provide_skills_to_agent_without_skills_field.yaml new file mode 100644 index 000000000..0c678deab --- /dev/null +++ b/test/snapshots/skills/should_not_provide_skills_to_agent_without_skills_field.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hello briefly using the test skill. + - role: assistant + content: Hello! I'm GitHub Copilot CLI, ready to help you with your software engineering tasks. From 95c12b113d34f3ca70b8ce435703789e00785def Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 3 Apr 2026 10:43:14 -0700 Subject: [PATCH 2/8] docs: update skills semantics to eager injection model Update type comments, docs, and test descriptions to reflect that per-agent skills are eagerly injected into the agent's context at startup rather than filtered for invocation. Sub-agents do not inherit skills from the parent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/features/custom-agents.md | 6 +++--- docs/features/skills.md | 4 ++-- dotnet/src/Types.cs | 9 +++++---- dotnet/test/SkillsTests.cs | 4 ++-- go/internal/e2e/skills_test.go | 4 ++-- go/types.go | 2 +- nodejs/src/types.ts | 9 +++++---- nodejs/test/e2e/skills.test.ts | 4 ++-- python/copilot/session.py | 2 +- python/e2e/test_skills.py | 8 ++++---- 10 files changed, 27 insertions(+), 25 deletions(-) diff --git a/docs/features/custom-agents.md b/docs/features/custom-agents.md index b6cb6e96b..f3c508922 100644 --- a/docs/features/custom-agents.md +++ b/docs/features/custom-agents.md @@ -252,7 +252,7 @@ try (var client = new CopilotClient()) { | `prompt` | `string` | ✅ | System prompt for the agent | | `mcpServers` | `object` | | MCP server configurations specific to this agent | | `infer` | `boolean` | | Whether the runtime can auto-select this agent (default: `true`) | -| `skills` | `string[]` | | List of skill names available to this agent | +| `skills` | `string[]` | | Skill names to preload into the agent's context at startup | > **Tip:** A good `description` helps the runtime match user intent to the right agent. Be specific about the agent's expertise and capabilities. @@ -264,7 +264,7 @@ In addition to per-agent configuration above, you can set `agent` on the **sessi ## Per-Agent Skills -You can scope skills to individual agents using the `skills` property. Skills are **opt-in** — agents have no access to skills by default. The skill names listed in `skills` are resolved from the session-level `skillDirectories`. +You can preload skills into an agent's context using the `skills` property. When specified, the **full content** of each listed skill is eagerly injected into the agent's context at startup — the agent doesn't need to invoke a skill tool; the instructions are already present. Skills are **opt-in**: agents receive no skills by default, and sub-agents do not inherit skills from the parent. Skill names are resolved from the session-level `skillDirectories`. ```typescript const session = await client.createSession({ @@ -287,7 +287,7 @@ const session = await client.createSession({ }); ``` -In this example, `security-auditor` can invoke only `security-scan` and `dependency-check`, while `docs-writer` can invoke only `markdown-lint`. An agent without a `skills` field has no access to any skills. +In this example, `security-auditor` starts with `security-scan` and `dependency-check` already injected into its context, while `docs-writer` starts with `markdown-lint`. An agent without a `skills` field receives no skill content. ## Selecting an Agent at Session Creation diff --git a/docs/features/skills.md b/docs/features/skills.md index 8f1e90dc3..cb42f4674 100644 --- a/docs/features/skills.md +++ b/docs/features/skills.md @@ -364,7 +364,7 @@ The markdown body contains the instructions that are injected into the session c ### Skills + Custom Agents -Skills can be scoped to individual custom agents using the `skills` property. Skills are **opt-in** — agents get no skills by default. Skill names are resolved from the session-level `skillDirectories`. +Skills listed in an agent's `skills` field are **eagerly preloaded** — their full content is injected into the agent's context at startup, so the agent has access to the skill instructions immediately without needing to invoke a skill tool. Skill names are resolved from the session-level `skillDirectories`. ```typescript const session = await client.createSession({ @@ -379,7 +379,7 @@ const session = await client.createSession({ }); ``` -> **Note:** When `skills` is omitted, the agent has **no** access to skills. This is an opt-in model — you must explicitly list the skills each agent needs. +> **Note:** Skills are opt-in — when `skills` is omitted, no skill content is injected. Sub-agents do not inherit skills from the parent; you must list them explicitly per agent. ### Skills + MCP Servers diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index ce5be2395..3b1b28034 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1640,10 +1640,11 @@ public class CustomAgentConfig public bool? Infer { get; set; } /// - /// List of skill names available to this agent. - /// Skills are resolved by name from the session's loaded skill pool (configured via skillDirectories). - /// When set, only the listed skills can be invoked by this agent. - /// When omitted, the agent has no access to skills (opt-in model). + /// List of skill names to preload into this agent's context. + /// When set, the full content of each listed skill is eagerly injected into + /// the agent's context at startup. Skills are resolved by name from the + /// session's configured skill directories (skillDirectories). + /// When omitted, no skills are injected (opt-in model). /// [JsonPropertyName("skills")] public List? Skills { get; set; } diff --git a/dotnet/test/SkillsTests.cs b/dotnet/test/SkillsTests.cs index 4f1baed99..6082549b3 100644 --- a/dotnet/test/SkillsTests.cs +++ b/dotnet/test/SkillsTests.cs @@ -110,7 +110,7 @@ public async Task Should_Allow_Agent_With_Skills_To_Invoke_Skill() Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); - // The agent has Skills = ["test-skill"], so it should be able to invoke the skill + // The agent has Skills = ["test-skill"], so the skill content is preloaded into its context var message = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello briefly using the test skill." }); Assert.NotNull(message); Assert.Contains(SkillMarker, message!.Data.Content); @@ -140,7 +140,7 @@ public async Task Should_Not_Provide_Skills_To_Agent_Without_Skills_Field() Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); - // The agent has no Skills field, so it should NOT have access to skills + // The agent has no Skills field, so no skill content is injected var message = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello briefly using the test skill." }); Assert.NotNull(message); Assert.DoesNotContain(SkillMarker, message!.Data.Content); diff --git a/go/internal/e2e/skills_test.go b/go/internal/e2e/skills_test.go index 1a392d0c2..79fbab24f 100644 --- a/go/internal/e2e/skills_test.go +++ b/go/internal/e2e/skills_test.go @@ -131,7 +131,7 @@ func TestSkills(t *testing.T) { t.Fatalf("Failed to create session: %v", err) } - // The agent has Skills: ["test-skill"], so it should be able to invoke the skill + // The agent has Skills: ["test-skill"], so the skill content is preloaded into its context message, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ Prompt: "Say hello briefly using the test skill.", }) @@ -168,7 +168,7 @@ func TestSkills(t *testing.T) { t.Fatalf("Failed to create session: %v", err) } - // The agent has no Skills field, so it should NOT have access to skills + // The agent has no Skills field, so no skill content is injected message, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ Prompt: "Say hello briefly using the test skill.", }) diff --git a/go/types.go b/go/types.go index f9aa2de8d..39f548490 100644 --- a/go/types.go +++ b/go/types.go @@ -450,7 +450,7 @@ type CustomAgentConfig struct { MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` // Infer indicates whether the agent should be available for model inference Infer *bool `json:"infer,omitempty"` - // Skills is the list of skill names available to this agent (opt-in; omit for no skills) + // Skills is the list of skill names to preload into this agent's context at startup (opt-in; omit for none) Skills []string `json:"skills,omitempty"` } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index a5d1690bd..a0ce54285 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1107,10 +1107,11 @@ export interface CustomAgentConfig { */ infer?: boolean; /** - * List of skill names available to this agent. - * Skills are resolved by name from the session's loaded skill pool (configured via `skillDirectories`). - * When set, only the listed skills can be invoked by this agent. - * When omitted, the agent has no access to skills (opt-in model). + * List of skill names to preload into this agent's context. + * When set, the full content of each listed skill is eagerly injected into + * the agent's context at startup. Skills are resolved by name from the + * session's configured skill directories (`skillDirectories`). + * When omitted, no skills are injected (opt-in model). */ skills?: string[]; } diff --git a/nodejs/test/e2e/skills.test.ts b/nodejs/test/e2e/skills.test.ts index 6efc1a64d..5683ea062 100644 --- a/nodejs/test/e2e/skills.test.ts +++ b/nodejs/test/e2e/skills.test.ts @@ -112,7 +112,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY expect(session.sessionId).toBeDefined(); - // The agent has skills: ["test-skill"], so it should be able to invoke the skill + // The agent has skills: ["test-skill"], so the skill content is preloaded into its context const message = await session.sendAndWait({ prompt: "Say hello briefly using the test skill.", }); @@ -140,7 +140,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY expect(session.sessionId).toBeDefined(); - // The agent has no skills field, so it should NOT have access to skills + // The agent has no skills field, so no skill content is injected const message = await session.sendAndWait({ prompt: "Say hello briefly using the test skill.", }); diff --git a/python/copilot/session.py b/python/copilot/session.py index 5f681e503..5d33bd05b 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -766,7 +766,7 @@ class CustomAgentConfig(TypedDict, total=False): # MCP servers specific to agent mcp_servers: NotRequired[dict[str, MCPServerConfig]] infer: NotRequired[bool] # Whether agent is available for model inference - # List of skill names available to this agent (opt-in; omit for no skills) + # Skill names to preload into this agent's context at startup (opt-in; omit for none) skills: NotRequired[list[str]] diff --git a/python/e2e/test_skills.py b/python/e2e/test_skills.py index d264d8bf8..ce943185b 100644 --- a/python/e2e/test_skills.py +++ b/python/e2e/test_skills.py @@ -89,7 +89,7 @@ async def test_should_not_apply_skill_when_disabled_via_disabledskills( await session.disconnect() async def test_should_allow_agent_with_skills_to_invoke_skill(self, ctx: E2ETestContext): - """Test that an agent with skills can invoke the specified skill""" + """Test that an agent with skills gets skill content preloaded into context""" skills_dir = create_skill_dir(ctx.work_dir) custom_agents: list[CustomAgentConfig] = [ { @@ -108,7 +108,7 @@ async def test_should_allow_agent_with_skills_to_invoke_skill(self, ctx: E2ETest assert session.session_id is not None - # The agent has skills: ["test-skill"], so it should be able to invoke the skill + # The agent has skills: ["test-skill"], so the skill content is preloaded into its context message = await session.send_and_wait("Say hello briefly using the test skill.") assert message is not None assert SKILL_MARKER in message.data.content @@ -118,7 +118,7 @@ async def test_should_allow_agent_with_skills_to_invoke_skill(self, ctx: E2ETest async def test_should_not_provide_skills_to_agent_without_skills_field( self, ctx: E2ETestContext ): - """Test that an agent without skills field gets no skills (opt-in model)""" + """Test that an agent without skills field gets no skill content (opt-in model)""" skills_dir = create_skill_dir(ctx.work_dir) custom_agents: list[CustomAgentConfig] = [ { @@ -136,7 +136,7 @@ async def test_should_not_provide_skills_to_agent_without_skills_field( assert session.session_id is not None - # The agent has no skills field, so it should NOT have access to skills + # The agent has no skills field, so no skill content is injected message = await session.send_and_wait("Say hello briefly using the test skill.") assert message is not None assert SKILL_MARKER not in message.data.content From 31b139c0197aeecfcf131a73481a0375bbbcdb65 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 9 Apr 2026 10:16:54 -0700 Subject: [PATCH 3/8] docs: remove agentName from skill.invoked event table The runtime does not emit agentName on the skill.invoked event. The agent name is used only for internal logging during eager skill loading, not as event data. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/features/streaming-events.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/features/streaming-events.md b/docs/features/streaming-events.md index 5fd903ef7..9dde8f21b 100644 --- a/docs/features/streaming-events.md +++ b/docs/features/streaming-events.md @@ -633,7 +633,6 @@ A skill was activated for the current conversation. | `allowedTools` | `string[]` | | Tools auto-approved while this skill is active | | `pluginName` | `string` | | Plugin the skill originated from | | `pluginVersion` | `string` | | Plugin version | -| `agentName` | `string` | | Name of the agent that invoked the skill, when invoked by a custom agent | --- From 3bd57a194dc268492a1179575b7658b18e6ac51b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 9 Apr 2026 10:23:32 -0700 Subject: [PATCH 4/8] fix: address PR review feedback for per-agent skills (#995) - Add skills field to Python wire format converter - Explicitly select agents in all E2E tests for deterministic behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/SkillsTests.cs | 6 ++++-- go/internal/e2e/skills_test.go | 2 ++ nodejs/test/e2e/skills.test.ts | 2 ++ python/copilot/client.py | 2 ++ python/e2e/test_skills.py | 2 ++ 5 files changed, 12 insertions(+), 2 deletions(-) diff --git a/dotnet/test/SkillsTests.cs b/dotnet/test/SkillsTests.cs index 6082549b3..0cae1f58f 100644 --- a/dotnet/test/SkillsTests.cs +++ b/dotnet/test/SkillsTests.cs @@ -105,7 +105,8 @@ public async Task Should_Allow_Agent_With_Skills_To_Invoke_Skill() var session = await CreateSessionAsync(new SessionConfig { SkillDirectories = [skillsDir], - CustomAgents = customAgents + CustomAgents = customAgents, + Agent = "skill-agent" }); Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); @@ -135,7 +136,8 @@ public async Task Should_Not_Provide_Skills_To_Agent_Without_Skills_Field() var session = await CreateSessionAsync(new SessionConfig { SkillDirectories = [skillsDir], - CustomAgents = customAgents + CustomAgents = customAgents, + Agent = "no-skill-agent" }); Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); diff --git a/go/internal/e2e/skills_test.go b/go/internal/e2e/skills_test.go index 79fbab24f..31e358635 100644 --- a/go/internal/e2e/skills_test.go +++ b/go/internal/e2e/skills_test.go @@ -126,6 +126,7 @@ func TestSkills(t *testing.T) { OnPermissionRequest: copilot.PermissionHandler.ApproveAll, SkillDirectories: []string{skillsDir}, CustomAgents: customAgents, + Agent: "skill-agent", }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -163,6 +164,7 @@ func TestSkills(t *testing.T) { OnPermissionRequest: copilot.PermissionHandler.ApproveAll, SkillDirectories: []string{skillsDir}, CustomAgents: customAgents, + Agent: "no-skill-agent", }) if err != nil { t.Fatalf("Failed to create session: %v", err) diff --git a/nodejs/test/e2e/skills.test.ts b/nodejs/test/e2e/skills.test.ts index 5683ea062..973e2f329 100644 --- a/nodejs/test/e2e/skills.test.ts +++ b/nodejs/test/e2e/skills.test.ts @@ -108,6 +108,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY onPermissionRequest: approveAll, skillDirectories: [skillsDir], customAgents, + agent: "skill-agent", }); expect(session.sessionId).toBeDefined(); @@ -136,6 +137,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY onPermissionRequest: approveAll, skillDirectories: [skillsDir], customAgents, + agent: "no-skill-agent", }); expect(session.sessionId).toBeDefined(); diff --git a/python/copilot/client.py b/python/copilot/client.py index d260dcc91..2078dfc6c 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2158,6 +2158,8 @@ def _convert_custom_agent_to_wire_format( wire_agent["mcpServers"] = agent["mcp_servers"] if "infer" in agent: wire_agent["infer"] = agent["infer"] + if "skills" in agent: + wire_agent["skills"] = agent["skills"] return wire_agent async def _start_cli_server(self) -> None: diff --git a/python/e2e/test_skills.py b/python/e2e/test_skills.py index ce943185b..b5c5e6e7c 100644 --- a/python/e2e/test_skills.py +++ b/python/e2e/test_skills.py @@ -104,6 +104,7 @@ async def test_should_allow_agent_with_skills_to_invoke_skill(self, ctx: E2ETest on_permission_request=PermissionHandler.approve_all, skill_directories=[skills_dir], custom_agents=custom_agents, + agent="skill-agent", ) assert session.session_id is not None @@ -132,6 +133,7 @@ async def test_should_not_provide_skills_to_agent_without_skills_field( on_permission_request=PermissionHandler.approve_all, skill_directories=[skills_dir], custom_agents=custom_agents, + agent="no-skill-agent", ) assert session.session_id is not None From 6f3083b13f95c6ff2fc6c6f2c751de984732ea7f Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 9 Apr 2026 10:42:19 -0700 Subject: [PATCH 5/8] fix: update Go skills tests to use typed SessionEventData after rebase The generated_session_events.go on main changed from a flat Data struct to a SessionEventData interface with per-event typed structs. The agent skills test cases added in this PR were using the old message.Data.Content pattern instead of the type assertion pattern used elsewhere. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/internal/e2e/skills_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/go/internal/e2e/skills_test.go b/go/internal/e2e/skills_test.go index 31e358635..b91592d9d 100644 --- a/go/internal/e2e/skills_test.go +++ b/go/internal/e2e/skills_test.go @@ -140,8 +140,8 @@ func TestSkills(t *testing.T) { 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) + if md, ok := message.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(md.Content, skillMarker) { + t.Errorf("Expected message to contain skill marker '%s', got: %v", skillMarker, message.Data) } session.Disconnect() @@ -178,8 +178,8 @@ func TestSkills(t *testing.T) { 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 agent has no skills, got: %v", skillMarker, *message.Data.Content) + if md, ok := message.Data.(*copilot.AssistantMessageData); ok && strings.Contains(md.Content, skillMarker) { + t.Errorf("Expected message to NOT contain skill marker '%s' when agent has no skills, got: %v", skillMarker, md.Content) } session.Disconnect() From 556d2a198d85fb74d476f5548dcb0b6b789cdd60 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 13 Apr 2026 16:22:12 -0700 Subject: [PATCH 6/8] chore: revert unintentional package-lock.json changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/package-lock.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 236e89219..55c3a4f24 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1260,7 +1260,6 @@ "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1300,7 +1299,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -1629,7 +1627,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1957,7 +1954,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2866,7 +2862,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3285,7 +3280,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -3319,7 +3313,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3387,7 +3380,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", From 276df2febb8e6c61fee67e35cefdd795702686e0 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 13 Apr 2026 18:33:59 -0700 Subject: [PATCH 7/8] fix: update proxy and snapshots for eager skill injection The runtime now eagerly injects skill content into in the user message instead of using a skill tool call. Update the replay proxy to strip during normalization, and simplify the snapshot for agent-with-skills to match the new flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/harness/replayingCapiProxy.ts | 1 + ...low_agent_with_skills_to_invoke_skill.yaml | 34 ------------------- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index 03dcd190f..a63c5b123 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -805,6 +805,7 @@ function normalizeUserMessage(content: string): string { return content .replace(/.*?<\/current_datetime>/g, "") .replace(/[\s\S]*?<\/reminder>/g, "") + .replace(/[\s\S]*?<\/agent_instructions>/g, "") .replace( /Please create a detailed summary of the conversation so far\. The history is being compacted[\s\S]*/, "${compaction_prompt}", diff --git a/test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.yaml b/test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.yaml index 7d364fcbd..007c5c1c5 100644 --- a/test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.yaml +++ b/test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.yaml @@ -6,39 +6,5 @@ conversations: content: ${system} - role: user content: Say hello briefly using the test skill. - - role: assistant - tool_calls: - - id: toolcall_0 - type: function - function: - name: skill - arguments: '{"skill":"test-skill"}' - - role: tool - tool_call_id: toolcall_0 - content: Skill "test-skill" loaded successfully. Follow the instructions in the skill context. - - role: user - content: >- - - - Base directory for this skill: ${workdir}/.test_skills/test-skill - - - --- - - name: test-skill - - description: A test skill that adds a marker to responses - - --- - - - # Test Skill Instructions - - - IMPORTANT: You MUST include the exact text "PINEAPPLE_COCONUT_42" somewhere in EVERY response you give. This - is a mandatory requirement. Include it naturally in your response. - - - - role: assistant content: Hello! PINEAPPLE_COCONUT_42 - I'm ready to help you with your tasks today. From 70ec03297c904aaf40c710ebca9afbcb00223040 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 13 Apr 2026 18:56:15 -0700 Subject: [PATCH 8/8] test: add agent_instructions normalization tests for replay proxy Add two regression tests validating that blocks are properly stripped during user message normalization, including the case where skill-context is nested inside agent_instructions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/harness/replayingCapiProxy.test.ts | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/harness/replayingCapiProxy.test.ts b/test/harness/replayingCapiProxy.test.ts index 6fcaed5e2..f19674052 100644 --- a/test/harness/replayingCapiProxy.test.ts +++ b/test/harness/replayingCapiProxy.test.ts @@ -302,6 +302,52 @@ describe("ReplayingCapiProxy", () => { ); }); + test("strips agent_instructions from user messages", async () => { + const requestBody = JSON.stringify({ + messages: [ + { + role: "user", + content: + "\nYou are a helpful test agent.\n\n\n\n\nSay hello briefly.", + }, + ], + }); + const responseBody = JSON.stringify({ + choices: [{ message: { role: "assistant", content: "Hello!" } }], + }); + + const outputPath = await createProxy([ + { url: "/chat/completions", requestBody, responseBody }, + ]); + + const result = await readYamlOutput(outputPath); + expect(result.conversations[0].messages[0].content).toBe( + "Say hello briefly.", + ); + }); + + test("strips agent_instructions containing skill-context from user messages", async () => { + const requestBody = JSON.stringify({ + messages: [ + { + role: "user", + content: + '\n\nSkill content here\n\nYou are a helpful agent.\n\n\nSay hello.', + }, + ], + }); + const responseBody = JSON.stringify({ + choices: [{ message: { role: "assistant", content: "Hi!" } }], + }); + + const outputPath = await createProxy([ + { url: "/chat/completions", requestBody, responseBody }, + ]); + + const result = await readYamlOutput(outputPath); + expect(result.conversations[0].messages[0].content).toBe("Say hello."); + }); + test("applies tool result normalizers to tool response content", async () => { const requestBody = JSON.stringify({ messages: [