From 527a63e949efefddac08ec732bf6c8b2211d194b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 12 Mar 2026 20:53:33 -0700 Subject: [PATCH 01/12] Add fine-grained system prompt customization (customize mode) Add a new 'customize' mode for systemMessage configuration, enabling SDK consumers to selectively override individual sections of the CLI system prompt while preserving the rest. This sits between the existing 'append' and 'replace' modes. 9 configurable sections: identity, tone, tool_efficiency, environment_context, code_change_rules, guidelines, safety, tool_instructions, custom_instructions. 4 override actions per section: replace, remove, append, prepend. Unknown section IDs are handled gracefully: content-bearing overrides are appended to additional instructions with a warning, and remove on unknown sections is silently ignored. Types and constants added to all 4 SDK languages (TypeScript, Python, Go, .NET). Documentation updated across all READMEs and getting-started guide. Companion runtime PR: github/copilot-agent-runtime#4751 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/getting-started.md | 24 +++++++++- dotnet/README.md | 28 +++++++++++ dotnet/src/Client.cs | 1 + dotnet/src/SdkProtocolVersion.cs | 5 +- dotnet/src/Types.cs | 79 ++++++++++++++++++++++++++++++-- go/README.md | 5 +- go/types.go | 33 +++++++++++-- nodejs/README.md | 40 +++++++++++++++- nodejs/src/index.ts | 5 +- nodejs/src/types.ts | 71 +++++++++++++++++++++++++++- nodejs/test/e2e/session.test.ts | 27 +++++++++++ python/README.md | 5 +- python/copilot/__init__.py | 18 ++++++++ python/copilot/types.py | 55 ++++++++++++++++++++-- 14 files changed, 375 insertions(+), 21 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 6c0aee72e..9d2056f07 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1235,7 +1235,7 @@ const session = await client.createSession({ ### Customize the System Message -Control the AI's behavior and personality: +Control the AI's behavior and personality by appending instructions: ```typescript const session = await client.createSession({ @@ -1245,6 +1245,28 @@ const session = await client.createSession({ }); ``` +For more fine-grained control, use `mode: "customize"` to override individual sections of the system prompt while preserving the rest: + +```typescript +const session = await client.createSession({ + systemMessage: { + mode: "customize", + sections: { + tone: { action: "replace", content: "Respond in a warm, professional tone. Be thorough in explanations." }, + code_change_rules: { action: "remove" }, + guidelines: { action: "append", content: "\n* Always cite data sources" }, + }, + content: "Focus on financial analysis and reporting.", + }, +}); +``` + +Available section IDs: `identity`, `tone`, `tool_efficiency`, `environment_context`, `code_change_rules`, `guidelines`, `safety`, `tool_instructions`, `custom_instructions`. + +Each override supports four actions: `replace`, `remove`, `append`, and `prepend`. Unknown section IDs are handled gracefully — content is appended to additional instructions and a warning is emitted; `remove` on unknown sections is silently ignored. + +See the language-specific SDK READMEs for examples in [TypeScript](../nodejs/README.md), [Python](../python/README.md), [Go](../go/README.md), and [C#](../dotnet/README.md). + --- ## Connecting to an External CLI Server diff --git a/dotnet/README.md b/dotnet/README.md index cb7dbba18..b1ae4afb1 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -509,6 +509,34 @@ var session = await client.CreateSessionAsync(new SessionConfig }); ``` +#### Customize Mode + +Use `Mode = SystemMessageMode.Customize` to selectively override individual sections of the prompt while preserving the rest: + +```csharp +var session = await client.CreateSessionAsync(new SessionConfig +{ + Model = "gpt-5", + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Sections = new Dictionary + { + [SystemPromptSections.Tone] = new() { Action = SectionOverrideAction.Replace, Content = "Respond in a warm, professional tone. Be thorough in explanations." }, + [SystemPromptSections.CodeChangeRules] = new() { Action = SectionOverrideAction.Remove }, + [SystemPromptSections.Guidelines] = new() { Action = SectionOverrideAction.Append, Content = "\n* Always cite data sources" }, + }, + Content = "Focus on financial analysis and reporting." + } +}); +``` + +Available section IDs are defined as constants on `SystemPromptSections`: `Identity`, `Tone`, `ToolEfficiency`, `EnvironmentContext`, `CodeChangeRules`, `Guidelines`, `Safety`, `ToolInstructions`, `CustomInstructions`. + +Each section override supports four actions: `Replace`, `Remove`, `Append`, and `Prepend`. Unknown section IDs are handled gracefully: content is appended to additional instructions, and `Remove` overrides are silently ignored. + +#### Replace Mode + For full control (removes all guardrails), use `Mode = SystemMessageMode.Replace`: ```csharp diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a9ad1fccd..49f95324c 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1683,6 +1683,7 @@ private static LogLevel MapLevel(TraceEventType eventType) [JsonSerializable(typeof(ProviderConfig))] [JsonSerializable(typeof(ResumeSessionRequest))] [JsonSerializable(typeof(ResumeSessionResponse))] + [JsonSerializable(typeof(SectionOverride))] [JsonSerializable(typeof(SessionMetadata))] [JsonSerializable(typeof(SystemMessageConfig))] [JsonSerializable(typeof(ToolCallResponseV2))] diff --git a/dotnet/src/SdkProtocolVersion.cs b/dotnet/src/SdkProtocolVersion.cs index f3d8f04c5..889af460b 100644 --- a/dotnet/src/SdkProtocolVersion.cs +++ b/dotnet/src/SdkProtocolVersion.cs @@ -16,8 +16,5 @@ internal static class SdkProtocolVersion /// /// Gets the SDK protocol version. /// - public static int GetVersion() - { - return Version; - } + public static int GetVersion() => Version; } diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 84e7feaed..b63663f94 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -968,7 +968,71 @@ public enum SystemMessageMode Append, /// Replace the default system message entirely. [JsonStringEnumMemberName("replace")] - Replace + Replace, + /// Override individual sections of the system prompt. + [JsonStringEnumMemberName("customize")] + Customize +} + +/// +/// Specifies the operation to perform on a system prompt section. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SectionOverrideAction +{ + /// Replace the section content entirely. + [JsonStringEnumMemberName("replace")] + Replace, + /// Remove the section from the prompt. + [JsonStringEnumMemberName("remove")] + Remove, + /// Append content after the existing section. + [JsonStringEnumMemberName("append")] + Append, + /// Prepend content before the existing section. + [JsonStringEnumMemberName("prepend")] + Prepend +} + +/// +/// Override operation for a single system prompt section. +/// +public class SectionOverride +{ + /// + /// The operation to perform on this section. + /// + public SectionOverrideAction Action { get; set; } + + /// + /// Content for the override. Optional for all actions. Ignored for remove. + /// + public string? Content { get; set; } +} + +/// +/// Known system prompt section identifiers for the "customize" mode. +/// +public static class SystemPromptSections +{ + /// Agent identity preamble and mode statement. + public const string Identity = "identity"; + /// Response style, conciseness rules, output formatting preferences. + public const string Tone = "tone"; + /// Tool usage patterns, parallel calling, batching guidelines. + public const string ToolEfficiency = "tool_efficiency"; + /// CWD, OS, git root, directory listing, available tools. + public const string EnvironmentContext = "environment_context"; + /// Coding rules, linting/testing, ecosystem tools, style. + public const string CodeChangeRules = "code_change_rules"; + /// Tips, behavioral best practices, behavioral guidelines. + public const string Guidelines = "guidelines"; + /// Environment limitations, prohibited actions, security policies. + public const string Safety = "safety"; + /// Per-tool usage instructions. + public const string ToolInstructions = "tool_instructions"; + /// Repository and organization custom instructions. + public const string CustomInstructions = "custom_instructions"; } /// @@ -977,13 +1041,21 @@ public enum SystemMessageMode public class SystemMessageConfig { /// - /// How the system message is applied (append or replace). + /// How the system message is applied (append, replace, or customize). /// public SystemMessageMode? Mode { get; set; } + /// - /// Content of the system message. + /// Content of the system message. Used by append and replace modes. + /// In customize mode, additional content appended after all sections. /// public string? Content { get; set; } + + /// + /// Section-level overrides for customize mode. + /// Keys are section identifiers (see ). + /// + public Dictionary? Sections { get; set; } } /// @@ -2061,6 +2133,7 @@ public class SetForegroundSessionResponse [JsonSerializable(typeof(SessionLifecycleEvent))] [JsonSerializable(typeof(SessionLifecycleEventMetadata))] [JsonSerializable(typeof(SessionListFilter))] +[JsonSerializable(typeof(SectionOverride))] [JsonSerializable(typeof(SessionMetadata))] [JsonSerializable(typeof(SetForegroundSessionResponse))] [JsonSerializable(typeof(SystemMessageConfig))] diff --git a/go/README.md b/go/README.md index 8cbb382c3..ca5a6da3c 100644 --- a/go/README.md +++ b/go/README.md @@ -150,7 +150,10 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `ReasoningEffort` (string): Reasoning effort level for models that support it ("low", "medium", "high", "xhigh"). Use `ListModels()` to check which models support this option. - `SessionID` (string): Custom session ID - `Tools` ([]Tool): Custom tools exposed to the CLI -- `SystemMessage` (\*SystemMessageConfig): System message configuration +- `SystemMessage` (\*SystemMessageConfig): System message configuration. Supports three modes: + - **append** (default): Appends `Content` after the SDK-managed prompt + - **replace**: Replaces the entire prompt with `Content` + - **customize**: Selectively override individual sections via `Sections` map (keys: `SectionIdentity`, `SectionTone`, `SectionToolEfficiency`, `SectionEnvironmentContext`, `SectionCodeChangeRules`, `SectionGuidelines`, `SectionSafety`, `SectionToolInstructions`, `SectionCustomInstructions`; values: `SectionOverride` with `Action` and optional `Content`) - `Provider` (\*ProviderConfig): Custom API provider configuration (BYOK). See [Custom Providers](#custom-providers) section. - `Streaming` (bool): Enable streaming delta events - `InfiniteSessions` (\*InfiniteSessionConfig): Automatic context compaction configuration diff --git a/go/types.go b/go/types.go index fd9968e3e..c5ac33fb8 100644 --- a/go/types.go +++ b/go/types.go @@ -111,6 +111,27 @@ func Float64(v float64) *float64 { return &v } +// Known system prompt section identifiers for the "customize" mode. +const ( + SectionIdentity = "identity" + SectionTone = "tone" + SectionToolEfficiency = "tool_efficiency" + SectionEnvironmentContext = "environment_context" + SectionCodeChangeRules = "code_change_rules" + SectionGuidelines = "guidelines" + SectionSafety = "safety" + SectionToolInstructions = "tool_instructions" + SectionCustomInstructions = "custom_instructions" +) + +// SectionOverride defines an override operation for a single system prompt section. +type SectionOverride struct { + // Action is the operation to perform: "replace", "remove", "append", or "prepend". + Action string `json:"action"` + // Content for the override. Optional for all actions. Ignored for "remove". + Content string `json:"content,omitempty"` +} + // SystemMessageAppendConfig is append mode: use CLI foundation with optional appended content. type SystemMessageAppendConfig struct { // Mode is optional, defaults to "append" @@ -129,11 +150,15 @@ type SystemMessageReplaceConfig struct { } // SystemMessageConfig represents system message configuration for session creation. -// Use SystemMessageAppendConfig for default behavior, SystemMessageReplaceConfig for full control. -// In Go, use one struct or the other based on your needs. +// - Append mode (default): SDK foundation + optional custom content +// - Replace mode: Full control, caller provides entire system message +// - Customize mode: Section-level overrides with graceful fallback +// +// In Go, use one struct and set fields appropriate for the desired mode. type SystemMessageConfig struct { - Mode string `json:"mode,omitempty"` - Content string `json:"content,omitempty"` + Mode string `json:"mode,omitempty"` + Content string `json:"content,omitempty"` + Sections map[string]SectionOverride `json:"sections,omitempty"` } // PermissionRequestResultKind represents the kind of a permission request result. diff --git a/nodejs/README.md b/nodejs/README.md index e9d23c529..639542184 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -473,7 +473,45 @@ const session = await client.createSession({ }); ``` -The SDK auto-injects environment context, tool instructions, and security guardrails. The default CLI persona is preserved, and your `content` is appended after SDK-managed sections. To change the persona or fully redefine the prompt, use `mode: "replace"`. +The SDK auto-injects environment context, tool instructions, and security guardrails. The default CLI persona is preserved, and your `content` is appended after SDK-managed sections. To change the persona or fully redefine the prompt, use `mode: "replace"` or `mode: "customize"`. + +#### Customize Mode + +Use `mode: "customize"` to selectively override individual sections of the prompt while preserving the rest: + +```typescript +import { SYSTEM_PROMPT_SECTIONS } from "@anthropic-ai/sdk"; +import type { SectionOverride, SystemPromptSection } from "@anthropic-ai/sdk"; + +const session = await client.createSession({ + model: "gpt-5", + systemMessage: { + mode: "customize", + sections: { + // Replace the tone/style section + tone: { action: "replace", content: "Respond in a warm, professional tone. Be thorough in explanations." }, + // Remove coding-specific rules + code_change_rules: { action: "remove" }, + // Append to existing guidelines + guidelines: { action: "append", content: "\n* Always cite data sources" }, + }, + // Additional instructions appended after all sections + content: "Focus on financial analysis and reporting.", + }, +}); +``` + +Available section IDs: `identity`, `tone`, `tool_efficiency`, `environment_context`, `code_change_rules`, `guidelines`, `safety`, `tool_instructions`, `custom_instructions`. Use the `SYSTEM_PROMPT_SECTIONS` constant for descriptions of each section. + +Each section override supports four actions: +- **`replace`** — Replace the section content entirely +- **`remove`** — Remove the section from the prompt +- **`append`** — Add content after the existing section +- **`prepend`** — Add content before the existing section + +Unknown section IDs are handled gracefully: content from `replace`/`append`/`prepend` overrides is appended to additional instructions, and `remove` overrides are silently ignored. + +#### Replace Mode For full control (removes all guardrails), use `mode: "replace"`: diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 214b80050..a717d8837 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -10,7 +10,7 @@ export { CopilotClient } from "./client.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; -export { defineTool, approveAll } from "./types.js"; +export { defineTool, approveAll, SYSTEM_PROMPT_SECTIONS } from "./types.js"; export type { ConnectionState, CopilotClientOptions, @@ -31,6 +31,7 @@ export type { PermissionRequest, PermissionRequestResult, ResumeSessionConfig, + SectionOverride, SessionConfig, SessionEvent, SessionEventHandler, @@ -44,7 +45,9 @@ export type { SessionMetadata, SystemMessageAppendConfig, SystemMessageConfig, + SystemMessageCustomizeConfig, SystemMessageReplaceConfig, + SystemPromptSection, TelemetryConfig, TraceContext, TraceContextProvider, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 9052bde52..1787e9932 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -272,6 +272,50 @@ export interface ToolCallResponsePayload { result: ToolResult; } +/** + * Known system prompt section identifiers for the "customize" mode. + * Each section corresponds to a distinct part of the system prompt. + */ +export type SystemPromptSection = + | "identity" + | "tone" + | "tool_efficiency" + | "environment_context" + | "code_change_rules" + | "guidelines" + | "safety" + | "tool_instructions" + | "custom_instructions"; + +/** Section metadata for documentation and tooling. */ +export const SYSTEM_PROMPT_SECTIONS: Record = { + identity: { description: "Agent identity preamble and mode statement" }, + tone: { description: "Response style, conciseness rules, output formatting preferences" }, + tool_efficiency: { description: "Tool usage patterns, parallel calling, batching guidelines" }, + environment_context: { description: "CWD, OS, git root, directory listing, available tools" }, + code_change_rules: { description: "Coding rules, linting/testing, ecosystem tools, style" }, + guidelines: { description: "Tips, behavioral best practices, behavioral guidelines" }, + safety: { description: "Environment limitations, prohibited actions, security policies" }, + tool_instructions: { description: "Per-tool usage instructions" }, + custom_instructions: { description: "Repository and organization custom instructions" }, +}; + +/** + * Override operation for a single system prompt section. + */ +export interface SectionOverride { + /** The operation to perform on this section. */ + action: "replace" | "remove" | "append" | "prepend"; + + /** + * Content for the override. Optional for all actions. + * - For replace, omitting content replaces with an empty string. + * - For append/prepend, content is added before/after the existing section. + * - Ignored for the remove action. + */ + content?: string; +} + /** * Append mode: Use CLI foundation with optional appended content (default). */ @@ -298,12 +342,37 @@ export interface SystemMessageReplaceConfig { content: string; } +/** + * Customize mode: Override individual sections of the system prompt. + * Keeps the SDK-managed prompt structure while allowing targeted modifications. + */ +export interface SystemMessageCustomizeConfig { + mode: "customize"; + + /** + * Override specific sections of the system prompt by section ID. + * Unknown section IDs gracefully fall back: content-bearing overrides are appended + * to additional instructions, and "remove" on unknown sections is a silent no-op. + */ + sections?: Partial>; + + /** + * Additional content appended after all sections. + * Equivalent to append mode's content field — provided for convenience. + */ + content?: string; +} + /** * System message configuration for session creation. * - Append mode (default): SDK foundation + optional custom content * - Replace mode: Full control, caller provides entire system message + * - Customize mode: Section-level overrides with graceful fallback */ -export type SystemMessageConfig = SystemMessageAppendConfig | SystemMessageReplaceConfig; +export type SystemMessageConfig = + | SystemMessageAppendConfig + | SystemMessageReplaceConfig + | SystemMessageCustomizeConfig; /** * Permission request types from the server diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index 1eb8a175d..dbcbed8bb 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -96,6 +96,33 @@ describe("Sessions", async () => { expect(systemMessage).toEqual(testSystemMessage); // Exact match }); + it("should create a session with customized systemMessage config", async () => { + const customTone = "Respond in a warm, professional tone. Be thorough in explanations."; + const appendedContent = "Always mention quarterly earnings."; + const session = await client.createSession({ + onPermissionRequest: approveAll, + systemMessage: { + mode: "customize", + sections: { + tone: { action: "replace", content: customTone }, + code_change_rules: { action: "remove" }, + }, + content: appendedContent, + }, + }); + + const assistantMessage = await session.sendAndWait({ prompt: "Who are you?" }); + expect(assistantMessage?.data.content).toBeDefined(); + + // Validate the system message sent to the model + const traffic = await openAiEndpoint.getExchanges(); + const systemMessage = getSystemMessage(traffic[0]); + expect(systemMessage).toContain(customTone); + expect(systemMessage).toContain(appendedContent); + // The code_change_rules section should have been removed + expect(systemMessage).not.toContain(""); + }); + it("should create a session with availableTools", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, diff --git a/python/README.md b/python/README.md index 2394c351a..b3de5f7cd 100644 --- a/python/README.md +++ b/python/README.md @@ -144,7 +144,10 @@ All parameters are keyword-only: - `client_name` (str): Client name to identify the application using the SDK. Included in the User-Agent header for API requests. - `reasoning_effort` (str): Reasoning effort level for models that support it ("low", "medium", "high", "xhigh"). Use `list_models()` to check which models support this option. - `tools` (list): Custom tools exposed to the CLI. -- `system_message` (dict): System message configuration. +- `system_message` (dict): System message configuration. Supports three modes: + - **append** (default): Appends `content` after the SDK-managed prompt + - **replace**: Replaces the entire prompt with `content` + - **customize**: Selectively override individual sections via `sections` dict (keys: `"identity"`, `"tone"`, `"tool_efficiency"`, `"environment_context"`, `"code_change_rules"`, `"guidelines"`, `"safety"`, `"tool_instructions"`, `"custom_instructions"`, `"last_instructions"`; values: `SectionOverride` with `action` and optional `content`) - `available_tools` (list[str]): List of tool names to allow. Takes precedence over `excluded_tools`. - `excluded_tools` (list[str]): List of tool names to disable. Ignored if `available_tools` is set. - `on_user_input_request` (callable): Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index e1fdf9253..cd26a83e2 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -30,13 +30,22 @@ PermissionRequestResult, PingResponse, ProviderConfig, + ResumeSessionConfig, + SectionOverride, SelectionAttachment, + SessionConfig, SessionContext, SessionEvent, SessionListFilter, SessionMetadata, StopError, SubprocessConfig, + SystemMessageAppendConfig, + SystemMessageConfig, + SystemMessageCustomizeConfig, + SystemMessageReplaceConfig, + SystemPromptSection, + SYSTEM_PROMPT_SECTIONS, TelemetryConfig, Tool, ToolHandler, @@ -71,13 +80,22 @@ "PermissionRequestResult", "PingResponse", "ProviderConfig", + "ResumeSessionConfig", + "SectionOverride", "SelectionAttachment", + "SessionConfig", "SessionContext", "SessionEvent", "SessionListFilter", "SessionMetadata", "StopError", "SubprocessConfig", + "SystemMessageAppendConfig", + "SystemMessageConfig", + "SystemMessageCustomizeConfig", + "SystemMessageReplaceConfig", + "SystemPromptSection", + "SYSTEM_PROMPT_SECTIONS", "TelemetryConfig", "Tool", "ToolHandler", diff --git a/python/copilot/types.py b/python/copilot/types.py index 17be065bc..905abf6f4 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from dataclasses import KW_ONLY, dataclass, field -from typing import Any, Literal, NotRequired, TypedDict +from typing import Any, Literal, NotRequired, Required, TypedDict # Import generated SessionEvent types from .generated.session_events import ( @@ -213,7 +213,41 @@ class Tool: # System message configuration (discriminated union) -# Use SystemMessageAppendConfig for default behavior, SystemMessageReplaceConfig for full control +# Use SystemMessageAppendConfig for default behavior, +# SystemMessageReplaceConfig for full control, +# or SystemMessageCustomizeConfig for section-level overrides. + +# Known system prompt section identifiers for the "customize" mode. +SystemPromptSection = Literal[ + "identity", + "tone", + "tool_efficiency", + "environment_context", + "code_change_rules", + "guidelines", + "safety", + "tool_instructions", + "custom_instructions", +] + +SYSTEM_PROMPT_SECTIONS: dict[SystemPromptSection, str] = { + "identity": "Agent identity preamble and mode statement", + "tone": "Response style, conciseness rules, output formatting preferences", + "tool_efficiency": "Tool usage patterns, parallel calling, batching guidelines", + "environment_context": "CWD, OS, git root, directory listing, available tools", + "code_change_rules": "Coding rules, linting/testing, ecosystem tools, style", + "guidelines": "Tips, behavioral best practices, behavioral guidelines", + "safety": "Environment limitations, prohibited actions, security policies", + "tool_instructions": "Per-tool usage instructions", + "custom_instructions": "Repository and organization custom instructions", +} + + +class SectionOverride(TypedDict, total=False): + """Override operation for a single system prompt section.""" + + action: Required[Literal["replace", "remove", "append", "prepend"]] + content: NotRequired[str] class SystemMessageAppendConfig(TypedDict, total=False): @@ -235,8 +269,21 @@ class SystemMessageReplaceConfig(TypedDict): content: str -# Union type - use one or the other -SystemMessageConfig = SystemMessageAppendConfig | SystemMessageReplaceConfig +class SystemMessageCustomizeConfig(TypedDict, total=False): + """ + Customize mode: Override individual sections of the system prompt. + Keeps the SDK-managed prompt structure while allowing targeted modifications. + """ + + mode: Required[Literal["customize"]] + sections: NotRequired[dict[SystemPromptSection, SectionOverride]] + content: NotRequired[str] + + +# Union type - use one based on your needs +SystemMessageConfig = ( + SystemMessageAppendConfig | SystemMessageReplaceConfig | SystemMessageCustomizeConfig +) # Permission result types From a0787741a8497b63c202a968875a17af8bdeb8a9 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 13 Mar 2026 09:18:39 -0700 Subject: [PATCH 02/12] Address PR review feedback: fix docs, add examples and E2E tests - Fix incorrect package name in nodejs/README.md (@anthropic-ai/sdk -> @github/copilot-sdk) - Add standalone 'System Message Customization' sections with full code examples to Python and Go READMEs (matching TypeScript/.NET) - Add E2E tests for customize mode to Python, Go, and .NET (matching existing Node.js E2E test coverage) - Fix 'end of the prompt' wording in docs to 'additional instructions' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/SessionTests.cs | 31 +++++++++++++++++++++ go/README.md | 46 +++++++++++++++++++++++++++++++ go/internal/e2e/session_test.go | 45 +++++++++++++++++++++++++++++++ nodejs/README.md | 4 +-- python/README.md | 48 +++++++++++++++++++++++++++++++++ python/e2e/test_session.py | 29 ++++++++++++++++++++ 6 files changed, 201 insertions(+), 2 deletions(-) diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index 30a9135a5..5aecaccba 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -91,6 +91,37 @@ public async Task Should_Create_A_Session_With_Replaced_SystemMessage_Config() Assert.Equal(testSystemMessage, GetSystemMessage(traffic[0])); } + [Fact] + public async Task Should_Create_A_Session_With_Customized_SystemMessage_Config() + { + var customTone = "Respond in a warm, professional tone. Be thorough in explanations."; + var appendedContent = "Always mention quarterly earnings."; + var session = await CreateSessionAsync(new SessionConfig + { + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Sections = new Dictionary + { + [SystemPromptSections.Tone] = new() { Action = SectionOverrideAction.Replace, Content = customTone }, + [SystemPromptSections.CodeChangeRules] = new() { Action = SectionOverrideAction.Remove }, + }, + Content = appendedContent + } + }); + + await session.SendAsync(new MessageOptions { Prompt = "Who are you?" }); + var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); + Assert.NotNull(assistantMessage); + + var traffic = await Ctx.GetExchangesAsync(); + Assert.NotEmpty(traffic); + var systemMessage = GetSystemMessage(traffic[0]); + Assert.Contains(customTone, systemMessage); + Assert.Contains(appendedContent, systemMessage); + Assert.DoesNotContain("", systemMessage); + } + [Fact] public async Task Should_Create_A_Session_With_AvailableTools() { diff --git a/go/README.md b/go/README.md index ca5a6da3c..c1504c0ba 100644 --- a/go/README.md +++ b/go/README.md @@ -182,6 +182,52 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `Bool(v bool) *bool` - Helper to create bool pointers for `AutoStart` option +### System Message Customization + +Control the system prompt using `SystemMessage` in session config: + +```go +session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + SystemMessage: &copilot.SystemMessageConfig{ + Content: "Always check for security vulnerabilities before suggesting changes.", + }, +}) +``` + +The SDK auto-injects environment context, tool instructions, and security guardrails. The default CLI persona is preserved, and your `Content` is appended after SDK-managed sections. To change the persona or fully redefine the prompt, use `Mode: "replace"` or `Mode: "customize"`. + +#### Customize Mode + +Use `Mode: "customize"` to selectively override individual sections of the prompt while preserving the rest: + +```go +session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "customize", + Sections: map[string]copilot.SectionOverride{ + // Replace the tone/style section + copilot.SectionTone: {Action: "replace", Content: "Respond in a warm, professional tone. Be thorough in explanations."}, + // Remove coding-specific rules + copilot.SectionCodeChangeRules: {Action: "remove"}, + // Append to existing guidelines + copilot.SectionGuidelines: {Action: "append", Content: "\n* Always cite data sources"}, + }, + // Additional instructions appended after all sections + Content: "Focus on financial analysis and reporting.", + }, +}) +``` + +Available section constants: `SectionIdentity`, `SectionTone`, `SectionToolEfficiency`, `SectionEnvironmentContext`, `SectionCodeChangeRules`, `SectionGuidelines`, `SectionSafety`, `SectionToolInstructions`, `SectionCustomInstructions`. + +Each section override supports four actions: +- **`replace`** — Replace the section content entirely +- **`remove`** — Remove the section from the prompt +- **`append`** — Add content after the existing section +- **`prepend`** — Add content before the existing section + +Unknown section IDs are handled gracefully: content from `replace`/`append`/`prepend` overrides is appended to additional instructions, and `remove` overrides are silently ignored. + ## Image Support The SDK supports image attachments via the `Attachments` field in `MessageOptions`. You can attach images by providing their file path, or by passing base64-encoded data directly using a blob attachment: diff --git a/go/internal/e2e/session_test.go b/go/internal/e2e/session_test.go index 1eaeacd1e..7f1817da9 100644 --- a/go/internal/e2e/session_test.go +++ b/go/internal/e2e/session_test.go @@ -184,6 +184,51 @@ func TestSession(t *testing.T) { } }) + t.Run("should create a session with customized systemMessage config", func(t *testing.T) { + ctx.ConfigureForTest(t) + + customTone := "Respond in a warm, professional tone. Be thorough in explanations." + appendedContent := "Always mention quarterly earnings." + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "customize", + Sections: map[string]copilot.SectionOverride{ + copilot.SectionTone: {Action: "replace", Content: customTone}, + copilot.SectionCodeChangeRules: {Action: "remove"}, + }, + Content: appendedContent, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Who are you?"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // Validate the system message sent to the model + traffic, err := ctx.GetExchanges() + if err != nil { + t.Fatalf("Failed to get exchanges: %v", err) + } + if len(traffic) == 0 { + t.Fatal("Expected at least one exchange") + } + systemMessage := getSystemMessage(traffic[0]) + if !strings.Contains(systemMessage, customTone) { + t.Errorf("Expected system message to contain custom tone, got %q", systemMessage) + } + if !strings.Contains(systemMessage, appendedContent) { + t.Errorf("Expected system message to contain appended content, got %q", systemMessage) + } + if strings.Contains(systemMessage, "") { + t.Error("Expected system message to NOT contain code_change_instructions (it was removed)") + } + }) + t.Run("should create a session with availableTools", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/nodejs/README.md b/nodejs/README.md index 639542184..16367406c 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -480,8 +480,8 @@ The SDK auto-injects environment context, tool instructions, and security guardr Use `mode: "customize"` to selectively override individual sections of the prompt while preserving the rest: ```typescript -import { SYSTEM_PROMPT_SECTIONS } from "@anthropic-ai/sdk"; -import type { SectionOverride, SystemPromptSection } from "@anthropic-ai/sdk"; +import { SYSTEM_PROMPT_SECTIONS } from "@github/copilot-sdk"; +import type { SectionOverride, SystemPromptSection } from "@github/copilot-sdk"; const session = await client.createSession({ model: "gpt-5", diff --git a/python/README.md b/python/README.md index b3de5f7cd..18f1139e4 100644 --- a/python/README.md +++ b/python/README.md @@ -220,6 +220,54 @@ unsubscribe() - `session.foreground` - A session became the foreground session in TUI - `session.background` - A session is no longer the foreground session +### System Message Customization + +Control the system prompt using `system_message` in session config: + +```python +session = await client.create_session( + system_message={ + "content": "Always check for security vulnerabilities before suggesting changes." + } +) +``` + +The SDK auto-injects environment context, tool instructions, and security guardrails. The default CLI persona is preserved, and your `content` is appended after SDK-managed sections. To change the persona or fully redefine the prompt, use `mode: "replace"` or `mode: "customize"`. + +#### Customize Mode + +Use `mode: "customize"` to selectively override individual sections of the prompt while preserving the rest: + +```python +from copilot import SYSTEM_PROMPT_SECTIONS + +session = await client.create_session( + system_message={ + "mode": "customize", + "sections": { + # Replace the tone/style section + "tone": {"action": "replace", "content": "Respond in a warm, professional tone. Be thorough in explanations."}, + # Remove coding-specific rules + "code_change_rules": {"action": "remove"}, + # Append to existing guidelines + "guidelines": {"action": "append", "content": "\n* Always cite data sources"}, + }, + # Additional instructions appended after all sections + "content": "Focus on financial analysis and reporting.", + } +) +``` + +Available section IDs: `"identity"`, `"tone"`, `"tool_efficiency"`, `"environment_context"`, `"code_change_rules"`, `"guidelines"`, `"safety"`, `"tool_instructions"`, `"custom_instructions"`. Use the `SYSTEM_PROMPT_SECTIONS` dict for descriptions of each section. + +Each section override supports four actions: +- **`replace`** — Replace the section content entirely +- **`remove`** — Remove the section from the prompt +- **`append`** — Add content after the existing section +- **`prepend`** — Add content before the existing section + +Unknown section IDs are handled gracefully: content from `replace`/`append`/`prepend` overrides is appended to additional instructions, and `remove` overrides are silently ignored. + ### Tools Define tools with automatic JSON schema generation using the `@define_tool` decorator and Pydantic models: diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index ffb0cd2bc..d0b80fadc 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -82,6 +82,35 @@ async def test_should_create_a_session_with_replaced_systemMessage_config( system_message = _get_system_message(traffic[0]) assert system_message == test_system_message # Exact match + async def test_should_create_a_session_with_customized_systemMessage_config( + self, ctx: E2ETestContext + ): + custom_tone = "Respond in a warm, professional tone. Be thorough in explanations." + appended_content = "Always mention quarterly earnings." + session = await ctx.client.create_session( + { + "system_message": { + "mode": "customize", + "sections": { + "tone": {"action": "replace", "content": custom_tone}, + "code_change_rules": {"action": "remove"}, + }, + "content": appended_content, + }, + "on_permission_request": PermissionHandler.approve_all, + } + ) + + assistant_message = await session.send_and_wait({"prompt": "Who are you?"}) + assert assistant_message is not None + + # Validate the system message sent to the model + traffic = await ctx.get_exchanges() + system_message = _get_system_message(traffic[0]) + assert custom_tone in system_message + assert appended_content in system_message + assert "" not in system_message + async def test_should_create_a_session_with_availableTools(self, ctx: E2ETestContext): session = await ctx.client.create_session( on_permission_request=PermissionHandler.approve_all, From bfe57ba1b429b88ad270720b9f8d5f630bed5814 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 17 Mar 2026 09:12:10 -0700 Subject: [PATCH 03/12] Add last_instructions configurable section Expose lastInstructions as a customizable section across all 4 SDKs, addressing review feedback about duplicate tool-efficiency blocks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/getting-started.md | 2 +- dotnet/README.md | 2 +- dotnet/src/Types.cs | 2 ++ go/README.md | 4 ++-- go/types.go | 1 + nodejs/README.md | 2 +- nodejs/src/types.ts | 4 +++- python/README.md | 2 +- python/copilot/types.py | 2 ++ 9 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 9d2056f07..9d4189f56 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1261,7 +1261,7 @@ const session = await client.createSession({ }); ``` -Available section IDs: `identity`, `tone`, `tool_efficiency`, `environment_context`, `code_change_rules`, `guidelines`, `safety`, `tool_instructions`, `custom_instructions`. +Available section IDs: `identity`, `tone`, `tool_efficiency`, `environment_context`, `code_change_rules`, `guidelines`, `safety`, `tool_instructions`, `custom_instructions`, `last_instructions`. Each override supports four actions: `replace`, `remove`, `append`, and `prepend`. Unknown section IDs are handled gracefully — content is appended to additional instructions and a warning is emitted; `remove` on unknown sections is silently ignored. diff --git a/dotnet/README.md b/dotnet/README.md index b1ae4afb1..cab1cf068 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -531,7 +531,7 @@ var session = await client.CreateSessionAsync(new SessionConfig }); ``` -Available section IDs are defined as constants on `SystemPromptSections`: `Identity`, `Tone`, `ToolEfficiency`, `EnvironmentContext`, `CodeChangeRules`, `Guidelines`, `Safety`, `ToolInstructions`, `CustomInstructions`. +Available section IDs are defined as constants on `SystemPromptSections`: `Identity`, `Tone`, `ToolEfficiency`, `EnvironmentContext`, `CodeChangeRules`, `Guidelines`, `Safety`, `ToolInstructions`, `CustomInstructions`, `LastInstructions`. Each section override supports four actions: `Replace`, `Remove`, `Append`, and `Prepend`. Unknown section IDs are handled gracefully: content is appended to additional instructions, and `Remove` overrides are silently ignored. diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index b63663f94..15c64f33c 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1033,6 +1033,8 @@ public static class SystemPromptSections public const string ToolInstructions = "tool_instructions"; /// Repository and organization custom instructions. public const string CustomInstructions = "custom_instructions"; + /// End-of-prompt instructions: parallel tool calling, persistence, task completion. + public const string LastInstructions = "last_instructions"; } /// diff --git a/go/README.md b/go/README.md index c1504c0ba..f29ef9fb7 100644 --- a/go/README.md +++ b/go/README.md @@ -153,7 +153,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `SystemMessage` (\*SystemMessageConfig): System message configuration. Supports three modes: - **append** (default): Appends `Content` after the SDK-managed prompt - **replace**: Replaces the entire prompt with `Content` - - **customize**: Selectively override individual sections via `Sections` map (keys: `SectionIdentity`, `SectionTone`, `SectionToolEfficiency`, `SectionEnvironmentContext`, `SectionCodeChangeRules`, `SectionGuidelines`, `SectionSafety`, `SectionToolInstructions`, `SectionCustomInstructions`; values: `SectionOverride` with `Action` and optional `Content`) + - **customize**: Selectively override individual sections via `Sections` map (keys: `SectionIdentity`, `SectionTone`, `SectionToolEfficiency`, `SectionEnvironmentContext`, `SectionCodeChangeRules`, `SectionGuidelines`, `SectionSafety`, `SectionToolInstructions`, `SectionCustomInstructions`, `SectionLastInstructions`; values: `SectionOverride` with `Action` and optional `Content`) - `Provider` (\*ProviderConfig): Custom API provider configuration (BYOK). See [Custom Providers](#custom-providers) section. - `Streaming` (bool): Enable streaming delta events - `InfiniteSessions` (\*InfiniteSessionConfig): Automatic context compaction configuration @@ -218,7 +218,7 @@ session, err := client.CreateSession(ctx, &copilot.SessionConfig{ }) ``` -Available section constants: `SectionIdentity`, `SectionTone`, `SectionToolEfficiency`, `SectionEnvironmentContext`, `SectionCodeChangeRules`, `SectionGuidelines`, `SectionSafety`, `SectionToolInstructions`, `SectionCustomInstructions`. +Available section constants: `SectionIdentity`, `SectionTone`, `SectionToolEfficiency`, `SectionEnvironmentContext`, `SectionCodeChangeRules`, `SectionGuidelines`, `SectionSafety`, `SectionToolInstructions`, `SectionCustomInstructions`, `SectionLastInstructions`. Each section override supports four actions: - **`replace`** — Replace the section content entirely diff --git a/go/types.go b/go/types.go index c5ac33fb8..605c3dd2f 100644 --- a/go/types.go +++ b/go/types.go @@ -122,6 +122,7 @@ const ( SectionSafety = "safety" SectionToolInstructions = "tool_instructions" SectionCustomInstructions = "custom_instructions" + SectionLastInstructions = "last_instructions" ) // SectionOverride defines an override operation for a single system prompt section. diff --git a/nodejs/README.md b/nodejs/README.md index 16367406c..cc5d62416 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -501,7 +501,7 @@ const session = await client.createSession({ }); ``` -Available section IDs: `identity`, `tone`, `tool_efficiency`, `environment_context`, `code_change_rules`, `guidelines`, `safety`, `tool_instructions`, `custom_instructions`. Use the `SYSTEM_PROMPT_SECTIONS` constant for descriptions of each section. +Available section IDs: `identity`, `tone`, `tool_efficiency`, `environment_context`, `code_change_rules`, `guidelines`, `safety`, `tool_instructions`, `custom_instructions`, `last_instructions`. Use the `SYSTEM_PROMPT_SECTIONS` constant for descriptions of each section. Each section override supports four actions: - **`replace`** — Replace the section content entirely diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 1787e9932..c7b943e09 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -285,7 +285,8 @@ export type SystemPromptSection = | "guidelines" | "safety" | "tool_instructions" - | "custom_instructions"; + | "custom_instructions" + | "last_instructions"; /** Section metadata for documentation and tooling. */ export const SYSTEM_PROMPT_SECTIONS: Record = { @@ -298,6 +299,7 @@ export const SYSTEM_PROMPT_SECTIONS: Record Date: Tue, 17 Mar 2026 13:13:59 -0700 Subject: [PATCH 04/12] Fix lint: prettier formatting, Python import order and line length Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/types.ts | 5 ++++- python/copilot/__init__.py | 4 ++-- python/copilot/types.py | 4 +++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index c7b943e09..a63731f8b 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -299,7 +299,10 @@ export const SYSTEM_PROMPT_SECTIONS: Record Date: Thu, 19 Mar 2026 18:41:26 -0700 Subject: [PATCH 05/12] Add transform operation for system prompt section customization Adds a new 'transform' action to SectionOverride that enables read-then-write mutation of system prompt sections via callbacks. The SDK intercepts function- valued actions before serialization, stores the callbacks locally, and handles the batched systemMessage.transform JSON-RPC callback from the runtime. Changes across all 4 SDKs (TypeScript, Python, Go, .NET): - Types: SectionTransformFn, SectionOverrideAction (TS/Python), Transform field (Go/.NET), SectionOverrideAction constants (Go) - Client: extractTransformCallbacks helper, transform callback registration, systemMessage.transform RPC handler - Session: transform callback storage and batched dispatch with error handling - E2E tests and shared snapshot YAML files Wire protocol: single batched RPC call with all transform sections, matching the runtime implementation in copilot-agent-runtime PR #5103. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 63 +++++- dotnet/src/Session.cs | 71 +++++++ dotnet/src/Types.cs | 43 +++- dotnet/test/SystemMessageTransformTests.cs | 139 +++++++++++++ go/client.go | 63 +++++- .../e2e/system_message_transform_test.go | 191 ++++++++++++++++++ go/session.go | 52 ++++- go/types.go | 27 ++- nodejs/src/client.ts | 95 ++++++++- nodejs/src/index.ts | 2 + nodejs/src/session.ts | 44 ++++ nodejs/src/types.ts | 23 ++- .../test/e2e/system_message_transform.test.ts | 123 +++++++++++ python/copilot/__init__.py | 8 +- python/copilot/client.py | 81 +++++++- python/copilot/session.py | 63 +++++- python/copilot/types.py | 11 +- python/e2e/test_system_message_transform.py | 120 +++++++++++ ...form_modifications_to_section_content.yaml | 33 +++ ...nsform_callbacks_with_section_content.yaml | 54 +++++ ...tic_overrides_and_transforms_together.yaml | 50 +++++ 21 files changed, 1330 insertions(+), 26 deletions(-) create mode 100644 dotnet/test/SystemMessageTransformTests.cs create mode 100644 go/internal/e2e/system_message_transform_test.go create mode 100644 nodejs/test/e2e/system_message_transform.test.ts create mode 100644 python/e2e/test_system_message_transform.py create mode 100644 test/snapshots/system_message_transform/should_apply_transform_modifications_to_section_content.yaml create mode 100644 test/snapshots/system_message_transform/should_invoke_transform_callbacks_with_section_content.yaml create mode 100644 test/snapshots/system_message_transform/should_work_with_static_overrides_and_transforms_together.yaml diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 49f95324c..99c0eff00 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -365,6 +365,44 @@ private async Task CleanupConnectionAsync(List? errors) } } + private static (SystemMessageConfig? wireConfig, Dictionary>>? callbacks) ExtractTransformCallbacks(SystemMessageConfig? systemMessage) + { + if (systemMessage?.Mode != SystemMessageMode.Customize || systemMessage.Sections == null) + { + return (systemMessage, null); + } + + var callbacks = new Dictionary>>(); + var wireSections = new Dictionary(); + + foreach (var (sectionId, sectionOverride) in systemMessage.Sections) + { + if (sectionOverride.Transform != null) + { + callbacks[sectionId] = sectionOverride.Transform; + wireSections[sectionId] = new SectionOverride { Action = SectionOverrideAction.Transform }; + } + else + { + wireSections[sectionId] = sectionOverride; + } + } + + if (callbacks.Count == 0) + { + return (systemMessage, null); + } + + var wireConfig = new SystemMessageConfig + { + Mode = systemMessage.Mode, + Content = systemMessage.Content, + Sections = wireSections + }; + + return (wireConfig, callbacks); + } + /// /// Creates a new Copilot session with the specified configuration. /// @@ -409,6 +447,8 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.Hooks.OnSessionEnd != null || config.Hooks.OnErrorOccurred != null); + var (wireSystemMessage, transformCallbacks) = ExtractTransformCallbacks(config.SystemMessage); + var sessionId = config.SessionId ?? Guid.NewGuid().ToString(); // Create and register the session before issuing the RPC so that @@ -424,6 +464,10 @@ public async Task CreateSessionAsync(SessionConfig config, Cance { session.RegisterHooks(config.Hooks); } + if (transformCallbacks != null) + { + session.RegisterTransformCallbacks(transformCallbacks); + } if (config.OnEvent != null) { session.On(config.OnEvent); @@ -440,7 +484,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.ClientName, config.ReasoningEffort, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), - config.SystemMessage, + wireSystemMessage, config.AvailableTools, config.ExcludedTools, config.Provider, @@ -519,6 +563,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.Hooks.OnSessionEnd != null || config.Hooks.OnErrorOccurred != null); + var (wireSystemMessage, transformCallbacks) = ExtractTransformCallbacks(config.SystemMessage); + // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. var session = new CopilotSession(sessionId, connection.Rpc, _logger); @@ -532,6 +578,10 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes { session.RegisterHooks(config.Hooks); } + if (transformCallbacks != null) + { + session.RegisterTransformCallbacks(transformCallbacks); + } if (config.OnEvent != null) { session.On(config.OnEvent); @@ -548,7 +598,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.Model, config.ReasoningEffort, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), - config.SystemMessage, + wireSystemMessage, config.AvailableTools, config.ExcludedTools, config.Provider, @@ -1222,6 +1272,7 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequestV2); rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest); rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); + rpc.AddLocalRpcMethod("systemMessage.transform", handler.OnSystemMessageTransform); rpc.StartListening(); // Transition state to Disconnected if the JSON-RPC connection drops @@ -1350,6 +1401,12 @@ public async Task OnHooksInvoke(string sessionId, string ho return new HooksInvokeResponse(output); } + public async Task OnSystemMessageTransform(string sessionId, JsonElement sections) + { + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); + return await session.HandleSystemMessageTransformAsync(sections); + } + // Protocol v2 backward-compatibility adapters public async Task OnToolCallV2(string sessionId, @@ -1683,9 +1740,9 @@ private static LogLevel MapLevel(TraceEventType eventType) [JsonSerializable(typeof(ProviderConfig))] [JsonSerializable(typeof(ResumeSessionRequest))] [JsonSerializable(typeof(ResumeSessionResponse))] - [JsonSerializable(typeof(SectionOverride))] [JsonSerializable(typeof(SessionMetadata))] [JsonSerializable(typeof(SystemMessageConfig))] + [JsonSerializable(typeof(SystemMessageTransformRpcResponse))] [JsonSerializable(typeof(ToolCallResponseV2))] [JsonSerializable(typeof(ToolDefinition))] [JsonSerializable(typeof(ToolResultAIContent))] diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 0014ec7f0..675a3e0c0 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -65,6 +65,8 @@ public sealed partial class CopilotSession : IAsyncDisposable private SessionHooks? _hooks; private readonly SemaphoreSlim _hooksLock = new(1, 1); + private Dictionary>>? _transformCallbacks; + private readonly SemaphoreSlim _transformCallbacksLock = new(1, 1); private SessionRpc? _sessionRpc; private int _isDisposed; @@ -653,6 +655,72 @@ internal void RegisterHooks(SessionHooks hooks) }; } + /// + /// Registers transform callbacks for system message sections. + /// + /// The transform callbacks keyed by section identifier. + internal void RegisterTransformCallbacks(Dictionary>>? callbacks) + { + _transformCallbacksLock.Wait(); + try + { + _transformCallbacks = callbacks; + } + finally + { + _transformCallbacksLock.Release(); + } + } + + /// + /// Handles a systemMessage.transform RPC call from the Copilot CLI. + /// + /// The raw JSON element containing sections to transform. + /// A task that resolves with the transformed sections. + internal async Task HandleSystemMessageTransformAsync(JsonElement sections) + { + Dictionary>>? callbacks; + await _transformCallbacksLock.WaitAsync(); + try + { + callbacks = _transformCallbacks; + } + finally + { + _transformCallbacksLock.Release(); + } + + var parsed = JsonSerializer.Deserialize( + sections.GetRawText(), + SessionJsonContext.Default.DictionaryStringSystemMessageTransformSection) ?? new(); + + var result = new Dictionary(); + foreach (var (sectionId, data) in parsed) + { + Func>? callback = null; + callbacks?.TryGetValue(sectionId, out callback); + + if (callback != null) + { + try + { + var transformed = await callback(data.Content ?? ""); + result[sectionId] = new SystemMessageTransformSection { Content = transformed }; + } + catch + { + result[sectionId] = new SystemMessageTransformSection { Content = data.Content ?? "" }; + } + } + else + { + result[sectionId] = new SystemMessageTransformSection { Content = data.Content ?? "" }; + } + } + + return new SystemMessageTransformRpcResponse { Sections = result }; + } + /// /// Gets the complete list of messages and events in the session. /// @@ -891,5 +959,8 @@ internal record SessionDestroyRequest [JsonSerializable(typeof(SessionEndHookOutput))] [JsonSerializable(typeof(ErrorOccurredHookInput))] [JsonSerializable(typeof(ErrorOccurredHookOutput))] + [JsonSerializable(typeof(SystemMessageTransformSection))] + [JsonSerializable(typeof(SystemMessageTransformRpcResponse))] + [JsonSerializable(typeof(Dictionary))] internal partial class SessionJsonContext : JsonSerializerContext; } diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 15c64f33c..d6530f9c7 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -991,7 +991,10 @@ public enum SectionOverrideAction Append, /// Prepend content before the existing section. [JsonStringEnumMemberName("prepend")] - Prepend + Prepend, + /// Transform the section content via a callback. + [JsonStringEnumMemberName("transform")] + Transform } /// @@ -1000,14 +1003,24 @@ public enum SectionOverrideAction public class SectionOverride { /// - /// The operation to perform on this section. + /// The operation to perform on this section. Ignored when Transform is set. /// - public SectionOverrideAction Action { get; set; } + [JsonPropertyName("action")] + public SectionOverrideAction? Action { get; set; } /// /// Content for the override. Optional for all actions. Ignored for remove. /// + [JsonPropertyName("content")] public string? Content { get; set; } + + /// + /// Transform callback. When set, takes precedence over Action. + /// Receives current section content, returns transformed content. + /// Not serialized — the SDK handles this locally. + /// + [JsonIgnore] + public Func>? Transform { get; set; } } /// @@ -2106,6 +2119,30 @@ public class SetForegroundSessionResponse public string? Error { get; set; } } +/// +/// Content data for a single system prompt section in a transform RPC call. +/// +public class SystemMessageTransformSection +{ + /// + /// The content of the section. + /// + [JsonPropertyName("content")] + public string? Content { get; set; } +} + +/// +/// Response to a systemMessage.transform RPC call. +/// +public class SystemMessageTransformRpcResponse +{ + /// + /// The transformed sections keyed by section identifier. + /// + [JsonPropertyName("sections")] + public Dictionary? Sections { get; set; } +} + [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, diff --git a/dotnet/test/SystemMessageTransformTests.cs b/dotnet/test/SystemMessageTransformTests.cs new file mode 100644 index 000000000..57fab5770 --- /dev/null +++ b/dotnet/test/SystemMessageTransformTests.cs @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * 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 SystemMessageTransformTests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "system_message_transform", output) +{ + [Fact] + public async Task Should_Invoke_Transform_Callbacks_With_Section_Content() + { + var identityCallbackInvoked = false; + var toneCallbackInvoked = false; + + var session = await CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Sections = new Dictionary + { + ["identity"] = new SectionOverride + { + Transform = async (content) => + { + Assert.False(string.IsNullOrEmpty(content)); + identityCallbackInvoked = true; + return content; + } + }, + ["tone"] = new SectionOverride + { + Transform = async (content) => + { + Assert.False(string.IsNullOrEmpty(content)); + toneCallbackInvoked = true; + return content; + } + } + } + } + }); + + await File.WriteAllTextAsync(Path.Combine(Ctx.WorkDir, "test.txt"), "Hello transform!"); + + await session.SendAsync(new MessageOptions + { + Prompt = "Read the contents of test.txt and tell me what it says" + }); + + await TestHelper.GetFinalAssistantMessageAsync(session); + + Assert.True(identityCallbackInvoked, "Expected identity transform callback to be invoked"); + Assert.True(toneCallbackInvoked, "Expected tone transform callback to be invoked"); + } + + [Fact] + public async Task Should_Apply_Transform_Modifications_To_Section_Content() + { + var transformCallbackInvoked = false; + + var session = await CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Sections = new Dictionary + { + ["identity"] = new SectionOverride + { + Transform = async (content) => + { + transformCallbackInvoked = true; + return content + "TRANSFORM_MARKER"; + } + } + } + } + }); + + await File.WriteAllTextAsync(Path.Combine(Ctx.WorkDir, "hello.txt"), "Hello!"); + + await session.SendAsync(new MessageOptions + { + Prompt = "Read the contents of hello.txt" + }); + + await TestHelper.GetFinalAssistantMessageAsync(session); + + Assert.True(transformCallbackInvoked, "Expected identity transform callback to be invoked"); + } + + [Fact] + public async Task Should_Work_With_Static_Overrides_And_Transforms_Together() + { + var transformCallbackInvoked = false; + + var session = await CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Sections = new Dictionary + { + ["safety"] = new SectionOverride + { + Action = SectionOverrideAction.Remove + }, + ["identity"] = new SectionOverride + { + Transform = async (content) => + { + transformCallbackInvoked = true; + return content; + } + } + } + } + }); + + await File.WriteAllTextAsync(Path.Combine(Ctx.WorkDir, "combo.txt"), "Combo test!"); + + await session.SendAsync(new MessageOptions + { + Prompt = "Read the contents of combo.txt and tell me what it says" + }); + + await TestHelper.GetFinalAssistantMessageAsync(session); + + Assert.True(transformCallbackInvoked, "Expected identity transform callback to be invoked"); + } +} diff --git a/go/client.go b/go/client.go index a2431ad39..be70a27d0 100644 --- a/go/client.go +++ b/go/client.go @@ -482,6 +482,36 @@ func (c *Client) ensureConnected(ctx context.Context) error { // }, // }, // }) +// extractTransformCallbacks separates transform callbacks from a SystemMessageConfig, +// returning a wire-safe config and a map of callbacks (nil if none). +func extractTransformCallbacks(config *SystemMessageConfig) (*SystemMessageConfig, map[string]SectionTransformFn) { + if config == nil || config.Mode != "customize" || len(config.Sections) == 0 { + return config, nil + } + + callbacks := make(map[string]SectionTransformFn) + wireSections := make(map[string]SectionOverride) + for id, override := range config.Sections { + if override.Transform != nil { + callbacks[id] = override.Transform + wireSections[id] = SectionOverride{Action: "transform"} + } else { + wireSections[id] = override + } + } + + if len(callbacks) == 0 { + return config, nil + } + + wireConfig := &SystemMessageConfig{ + Mode: config.Mode, + Content: config.Content, + Sections: wireSections, + } + return wireConfig, callbacks +} + func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Session, error) { if config == nil || config.OnPermissionRequest == nil { return nil, fmt.Errorf("an OnPermissionRequest handler is required when creating a session. For example, to allow all permissions, use &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}") @@ -497,7 +527,8 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.ReasoningEffort = config.ReasoningEffort req.ConfigDir = config.ConfigDir req.Tools = config.Tools - req.SystemMessage = config.SystemMessage + wireSystemMessage, transformCallbacks := extractTransformCallbacks(config.SystemMessage) + req.SystemMessage = wireSystemMessage req.AvailableTools = config.AvailableTools req.ExcludedTools = config.ExcludedTools req.Provider = config.Provider @@ -548,6 +579,9 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.Hooks != nil { session.registerHooks(config.Hooks) } + if transformCallbacks != nil { + session.registerTransformCallbacks(transformCallbacks) + } if config.OnEvent != nil { session.On(config.OnEvent) } @@ -616,7 +650,8 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.ClientName = config.ClientName req.Model = config.Model req.ReasoningEffort = config.ReasoningEffort - req.SystemMessage = config.SystemMessage + wireSystemMessage, transformCallbacks := extractTransformCallbacks(config.SystemMessage) + req.SystemMessage = wireSystemMessage req.Tools = config.Tools req.Provider = config.Provider req.AvailableTools = config.AvailableTools @@ -665,6 +700,9 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.Hooks != nil { session.registerHooks(config.Hooks) } + if transformCallbacks != nil { + session.registerTransformCallbacks(transformCallbacks) + } if config.OnEvent != nil { session.On(config.OnEvent) } @@ -1402,6 +1440,7 @@ func (c *Client) setupNotificationHandler() { c.client.SetRequestHandler("permission.request", jsonrpc2.RequestHandlerFor(c.handlePermissionRequestV2)) c.client.SetRequestHandler("userInput.request", jsonrpc2.RequestHandlerFor(c.handleUserInputRequest)) c.client.SetRequestHandler("hooks.invoke", jsonrpc2.RequestHandlerFor(c.handleHooksInvoke)) + c.client.SetRequestHandler("systemMessage.transform", jsonrpc2.RequestHandlerFor(c.handleSystemMessageTransform)) } func (c *Client) handleSessionEvent(req sessionEventRequest) { @@ -1468,6 +1507,26 @@ func (c *Client) handleHooksInvoke(req hooksInvokeRequest) (map[string]any, *jso return result, nil } +// handleSystemMessageTransform handles a system message transform request from the CLI server. +func (c *Client) handleSystemMessageTransform(req systemMessageTransformRequest) (systemMessageTransformResponse, *jsonrpc2.Error) { + if req.SessionID == "" { + return systemMessageTransformResponse{}, &jsonrpc2.Error{Code: -32602, Message: "invalid system message transform payload"} + } + + c.sessionsMux.Lock() + session, ok := c.sessions[req.SessionID] + c.sessionsMux.Unlock() + if !ok { + return systemMessageTransformResponse{}, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} + } + + resp, err := session.handleSystemMessageTransform(req.Sections) + if err != nil { + return systemMessageTransformResponse{}, &jsonrpc2.Error{Code: -32603, Message: err.Error()} + } + return resp, nil +} + // ======================================================================== // Protocol v2 backward-compatibility adapters // ======================================================================== diff --git a/go/internal/e2e/system_message_transform_test.go b/go/internal/e2e/system_message_transform_test.go new file mode 100644 index 000000000..d55c2d7d2 --- /dev/null +++ b/go/internal/e2e/system_message_transform_test.go @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package e2e + +import ( + "os" + "path/filepath" + "sync" + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" +) + +func TestSystemMessageTransform(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("should_invoke_transform_callbacks_with_section_content", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var identityContent string + var toneContent string + var mu sync.Mutex + identityCalled := false + toneCalled := false + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "customize", + Sections: map[string]copilot.SectionOverride{ + "identity": { + Transform: func(currentContent string) (string, error) { + mu.Lock() + identityCalled = true + identityContent = currentContent + mu.Unlock() + return currentContent, nil + }, + }, + "tone": { + Transform: func(currentContent string) (string, error) { + mu.Lock() + toneCalled = true + toneContent = currentContent + mu.Unlock() + return currentContent, nil + }, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + testFile := filepath.Join(ctx.WorkDir, "test.txt") + err = os.WriteFile(testFile, []byte("Hello transform!"), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Read the contents of test.txt and tell me what it says", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if !identityCalled { + t.Error("Expected identity transform callback to be invoked") + } + if !toneCalled { + t.Error("Expected tone transform callback to be invoked") + } + if identityContent == "" { + t.Error("Expected identity transform to receive non-empty content") + } + if toneContent == "" { + t.Error("Expected tone transform to receive non-empty content") + } + }) + + t.Run("should_apply_transform_modifications_to_section_content", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var receivedContent string + var mu sync.Mutex + transformCalled := false + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "customize", + Sections: map[string]copilot.SectionOverride{ + "identity": { + Transform: func(currentContent string) (string, error) { + mu.Lock() + transformCalled = true + receivedContent = currentContent + mu.Unlock() + return currentContent + "\nTRANSFORM_MARKER", nil + }, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + testFile := filepath.Join(ctx.WorkDir, "hello.txt") + err = os.WriteFile(testFile, []byte("Hello!"), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Read the contents of hello.txt", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if !transformCalled { + t.Error("Expected transform callback to be invoked") + } + if receivedContent == "" { + t.Error("Expected transform to receive non-empty content") + } + }) + + t.Run("should_work_with_static_overrides_and_transforms_together", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var mu sync.Mutex + transformCalled := false + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "customize", + Sections: map[string]copilot.SectionOverride{ + "safety": { + Action: copilot.SectionActionRemove, + }, + "identity": { + Transform: func(currentContent string) (string, error) { + mu.Lock() + transformCalled = true + mu.Unlock() + return currentContent, nil + }, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + testFile := filepath.Join(ctx.WorkDir, "combo.txt") + err = os.WriteFile(testFile, []byte("Combo test!"), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Read the contents of combo.txt and tell me what it says", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if !transformCalled { + t.Error("Expected identity transform callback to be invoked") + } + }) +} diff --git a/go/session.go b/go/session.go index 107ac9824..83f1de960 100644 --- a/go/session.go +++ b/go/session.go @@ -62,8 +62,10 @@ type Session struct { permissionMux sync.RWMutex userInputHandler UserInputHandler userInputMux sync.RWMutex - hooks *SessionHooks - hooksMux sync.RWMutex + hooks *SessionHooks + hooksMux sync.RWMutex + transformCallbacks map[string]SectionTransformFn + transformMu sync.Mutex // eventCh serializes user event handler dispatch. dispatchEvent enqueues; // a single goroutine (processEvents) dequeues and invokes handlers in FIFO order. @@ -446,6 +448,52 @@ func (s *Session) handleHooksInvoke(hookType string, rawInput json.RawMessage) ( } } +// registerTransformCallbacks registers transform callbacks for this session. +// +// Transform callbacks are invoked when the CLI requests system message section +// transforms. This method is internal and typically called when creating a session. +func (s *Session) registerTransformCallbacks(callbacks map[string]SectionTransformFn) { + s.transformMu.Lock() + defer s.transformMu.Unlock() + s.transformCallbacks = callbacks +} + +type systemMessageTransformRequest struct { + SessionID string `json:"sessionId"` + Sections map[string]struct{ Content string } `json:"sections"` +} + +type systemMessageTransformResponse struct { + Sections map[string]struct{ Content string } `json:"sections"` +} + +// handleSystemMessageTransform handles a system message transform request from the Copilot CLI. +// This is an internal method called by the SDK when the CLI requests section transforms. +func (s *Session) handleSystemMessageTransform(sections map[string]struct{ Content string }) (systemMessageTransformResponse, error) { + s.transformMu.Lock() + callbacks := s.transformCallbacks + s.transformMu.Unlock() + + result := make(map[string]struct{ Content string }) + for sectionID, data := range sections { + var callback SectionTransformFn + if callbacks != nil { + callback = callbacks[sectionID] + } + if callback != nil { + transformed, err := callback(data.Content) + if err != nil { + result[sectionID] = struct{ Content string }{Content: data.Content} + } else { + result[sectionID] = struct{ Content string }{Content: transformed} + } + } else { + result[sectionID] = struct{ Content string }{Content: data.Content} + } + } + return systemMessageTransformResponse{Sections: result}, nil +} + // dispatchEvent enqueues an event for delivery to user handlers and fires // broadcast handlers concurrently. // diff --git a/go/types.go b/go/types.go index 605c3dd2f..502d61c1c 100644 --- a/go/types.go +++ b/go/types.go @@ -125,12 +125,35 @@ const ( SectionLastInstructions = "last_instructions" ) +// SectionOverrideAction represents the action to perform on a system prompt section. +type SectionOverrideAction string + +const ( + // SectionActionReplace replaces section content entirely. + SectionActionReplace SectionOverrideAction = "replace" + // SectionActionRemove removes the section. + SectionActionRemove SectionOverrideAction = "remove" + // SectionActionAppend appends to existing section content. + SectionActionAppend SectionOverrideAction = "append" + // SectionActionPrepend prepends to existing section content. + SectionActionPrepend SectionOverrideAction = "prepend" +) + +// SectionTransformFn is a callback that receives the current content of a system prompt section +// and returns the transformed content. Used with the "transform" action to read-then-write +// modify sections at runtime. +type SectionTransformFn func(currentContent string) (string, error) + // SectionOverride defines an override operation for a single system prompt section. type SectionOverride struct { - // Action is the operation to perform: "replace", "remove", "append", or "prepend". - Action string `json:"action"` + // Action is the operation to perform: "replace", "remove", "append", "prepend", or "transform". + Action SectionOverrideAction `json:"action,omitempty"` // Content for the override. Optional for all actions. Ignored for "remove". Content string `json:"content,omitempty"` + // Transform is a callback invoked when Action is "transform". + // The runtime calls this with the current section content and uses the returned string. + // Excluded from JSON serialization; the SDK registers it as an RPC callback internally. + Transform SectionTransformFn `json:"-"` } // SystemMessageAppendConfig is append mode: use CLI foundation with optional appended content. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 46d932242..bb835d032 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -36,6 +36,7 @@ import type { GetStatusResponse, ModelInfo, ResumeSessionConfig, + SectionTransformFn, SessionConfig, SessionContext, SessionEvent, @@ -44,6 +45,7 @@ import type { SessionLifecycleHandler, SessionListFilter, SessionMetadata, + SystemMessageCustomizeConfig, TelemetryConfig, Tool, ToolCallRequestPayload, @@ -82,6 +84,47 @@ function toJsonSchema(parameters: Tool["parameters"]): Record | return parameters; } +/** + * Extract transform callbacks from a system message config and prepare the wire payload. + * Function-valued actions are replaced with `{ action: "transform" }` for serialization, + * and the original callbacks are returned in a separate map. + */ +function extractTransformCallbacks( + systemMessage: SessionConfig["systemMessage"] +): { + wirePayload: SessionConfig["systemMessage"]; + transformCallbacks: Map | undefined; +} { + if (!systemMessage || systemMessage.mode !== "customize" || !systemMessage.sections) { + return { wirePayload: systemMessage, transformCallbacks: undefined }; + } + + const transformCallbacks = new Map(); + const wireSections: Record = {}; + + for (const [sectionId, override] of Object.entries(systemMessage.sections)) { + if (!override) continue; + + if (typeof override.action === "function") { + transformCallbacks.set(sectionId, override.action); + wireSections[sectionId] = { action: "transform" }; + } else { + wireSections[sectionId] = { action: override.action, content: override.content }; + } + } + + if (transformCallbacks.size === 0) { + return { wirePayload: systemMessage, transformCallbacks: undefined }; + } + + const wirePayload: SystemMessageCustomizeConfig = { + ...systemMessage, + sections: wireSections as SystemMessageCustomizeConfig["sections"], + }; + + return { wirePayload, transformCallbacks }; +} + function getNodeExecPath(): string { if (process.versions.bun) { return "node"; @@ -605,6 +648,15 @@ export class CopilotClient { if (config.hooks) { session.registerHooks(config.hooks); } + + // Extract transform callbacks from system message config before serialization. + const { wirePayload: wireSystemMessage, transformCallbacks } = extractTransformCallbacks( + config.systemMessage + ); + if (transformCallbacks) { + session.registerTransformCallbacks(transformCallbacks); + } + if (config.onEvent) { session.on(config.onEvent); } @@ -624,7 +676,7 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, })), - systemMessage: config.systemMessage, + systemMessage: wireSystemMessage, availableTools: config.availableTools, excludedTools: config.excludedTools, provider: config.provider, @@ -711,6 +763,15 @@ export class CopilotClient { if (config.hooks) { session.registerHooks(config.hooks); } + + // Extract transform callbacks from system message config before serialization. + const { wirePayload: wireSystemMessage, transformCallbacks } = extractTransformCallbacks( + config.systemMessage + ); + if (transformCallbacks) { + session.registerTransformCallbacks(transformCallbacks); + } + if (config.onEvent) { session.on(config.onEvent); } @@ -723,7 +784,7 @@ export class CopilotClient { clientName: config.clientName, model: config.model, reasoningEffort: config.reasoningEffort, - systemMessage: config.systemMessage, + systemMessage: wireSystemMessage, availableTools: config.availableTools, excludedTools: config.excludedTools, tools: config.tools?.map((tool) => ({ @@ -1477,6 +1538,15 @@ export class CopilotClient { }): Promise<{ output?: unknown }> => await this.handleHooksInvoke(params) ); + this.connection.onRequest( + "systemMessage.transform", + async (params: { + sessionId: string; + sections: Record; + }): Promise<{ sections: Record }> => + await this.handleSystemMessageTransform(params) + ); + this.connection.onClose(() => { this.state = "disconnected"; }); @@ -1588,6 +1658,27 @@ export class CopilotClient { return { output }; } + private async handleSystemMessageTransform(params: { + sessionId: string; + sections: Record; + }): Promise<{ sections: Record }> { + if ( + !params || + typeof params.sessionId !== "string" || + !params.sections || + typeof params.sections !== "object" + ) { + throw new Error("Invalid systemMessage.transform payload"); + } + + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + + return await session._handleSystemMessageTransform(params.sections); + } + // ======================================================================== // Protocol v2 backward-compatibility adapters // ======================================================================== diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index a717d8837..f3788e168 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -32,6 +32,8 @@ export type { PermissionRequestResult, ResumeSessionConfig, SectionOverride, + SectionOverrideAction, + SectionTransformFn, SessionConfig, SessionEvent, SessionEventHandler, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 674526764..122f4ece8 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -17,6 +17,7 @@ import type { PermissionRequest, PermissionRequestResult, ReasoningEffort, + SectionTransformFn, SessionEvent, SessionEventHandler, SessionEventPayload, @@ -70,6 +71,7 @@ export class CopilotSession { private permissionHandler?: PermissionHandler; private userInputHandler?: UserInputHandler; private hooks?: SessionHooks; + private transformCallbacks?: Map; private _rpc: ReturnType | null = null; private traceContextProvider?: TraceContextProvider; @@ -517,6 +519,48 @@ export class CopilotSession { this.hooks = hooks; } + /** + * Registers transform callbacks for system message sections. + * + * @param callbacks - Map of section ID to transform callback, or undefined to clear + * @internal This method is typically called internally when creating a session. + */ + registerTransformCallbacks(callbacks?: Map): void { + this.transformCallbacks = callbacks; + } + + /** + * Handles a systemMessage.transform request from the runtime. + * Dispatches each section to its registered transform callback. + * + * @param sections - Map of section IDs to their current rendered content + * @returns A promise that resolves with the transformed sections + * @internal This method is for internal use by the SDK. + */ + async _handleSystemMessageTransform( + sections: Record + ): Promise<{ sections: Record }> { + const result: Record = {}; + + for (const [sectionId, { content }] of Object.entries(sections)) { + const callback = this.transformCallbacks?.get(sectionId); + if (callback) { + try { + const transformed = await callback(content); + result[sectionId] = { content: transformed }; + } catch (_error) { + // Callback failed — return original content + result[sectionId] = { content }; + } + } else { + // No callback for this section — pass through unchanged + result[sectionId] = { content }; + } + } + + return { sections: result }; + } + /** * Handles a permission request in the v2 protocol format (synchronous RPC). * Used as a back-compat adapter when connected to a v2 server. diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index a63731f8b..4bf1d99c3 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -305,12 +305,31 @@ export const SYSTEM_PROMPT_SECTIONS: Record string | Promise; + +/** + * Override action: a string literal for static overrides, or a callback for transforms. + * + * - `"replace"`: Replace section content entirely + * - `"remove"`: Remove the section + * - `"append"`: Append to existing section content + * - `"prepend"`: Prepend to existing section content + * - `function`: Transform callback — receives current section content, returns new content + */ +export type SectionOverrideAction = "replace" | "remove" | "append" | "prepend" | SectionTransformFn; + /** * Override operation for a single system prompt section. */ export interface SectionOverride { - /** The operation to perform on this section. */ - action: "replace" | "remove" | "append" | "prepend"; + /** + * The operation to perform on this section. + * Can be a string action or a transform callback function. + */ + action: SectionOverrideAction; /** * Content for the override. Optional for all actions. diff --git a/nodejs/test/e2e/system_message_transform.test.ts b/nodejs/test/e2e/system_message_transform.test.ts new file mode 100644 index 000000000..20211ae98 --- /dev/null +++ b/nodejs/test/e2e/system_message_transform.test.ts @@ -0,0 +1,123 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { writeFile } from "fs/promises"; +import { join } from "path"; +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("System message transform", async () => { + const { copilotClient: client, workDir } = await createSdkTestContext(); + + it("should invoke transform callbacks with section content", async () => { + const transformedSections: Record = {}; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + systemMessage: { + mode: "customize", + sections: { + identity: { + action: (content: string) => { + transformedSections["identity"] = content; + // Pass through unchanged + return content; + }, + }, + tone: { + action: (content: string) => { + transformedSections["tone"] = content; + return content; + }, + }, + }, + }, + }); + + await writeFile(join(workDir, "test.txt"), "Hello transform!"); + + await session.sendAndWait({ + prompt: "Read the contents of test.txt and tell me what it says", + }); + + // Transform callbacks should have been invoked with real section content + expect(Object.keys(transformedSections).length).toBe(2); + expect(transformedSections["identity"]).toBeDefined(); + expect(transformedSections["identity"]!.length).toBeGreaterThan(0); + expect(transformedSections["tone"]).toBeDefined(); + expect(transformedSections["tone"]!.length).toBeGreaterThan(0); + + await session.disconnect(); + }); + + it("should apply transform modifications to section content", async () => { + let originalContent = ""; + let transformedContent = ""; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + systemMessage: { + mode: "customize", + sections: { + identity: { + action: (content: string) => { + originalContent = content; + // Append a custom instruction via transform + transformedContent = content + "\nTRANSFORM_MARKER"; + return transformedContent; + }, + }, + }, + }, + }); + + await writeFile(join(workDir, "hello.txt"), "Hello!"); + + await session.sendAndWait({ + prompt: "Read the contents of hello.txt", + }); + + // Verify the transform callback was invoked and modified the content + expect(originalContent.length).toBeGreaterThan(0); + expect(transformedContent).toContain("TRANSFORM_MARKER"); + expect(transformedContent).toContain(originalContent); + + await session.disconnect(); + }); + + it("should work with static overrides and transforms together", async () => { + const transformedSections: Record = {}; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + systemMessage: { + mode: "customize", + sections: { + // Static override + safety: { action: "remove" }, + // Transform + identity: { + action: (content: string) => { + transformedSections["identity"] = content; + return content; + }, + }, + }, + }, + }); + + await writeFile(join(workDir, "combo.txt"), "Combo test!"); + + await session.sendAndWait({ + prompt: "Read the contents of combo.txt and tell me what it says", + }); + + // Transform should have been invoked + expect(transformedSections["identity"]).toBeDefined(); + expect(transformedSections["identity"]!.length).toBeGreaterThan(0); + + await session.disconnect(); + }); +}); diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index d3182eaed..e467fa887 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -30,11 +30,11 @@ PermissionRequestResult, PingResponse, ProviderConfig, - ResumeSessionConfig, SYSTEM_PROMPT_SECTIONS, SectionOverride, + SectionOverrideAction, + SectionTransformFn, SelectionAttachment, - SessionConfig, SessionContext, SessionEvent, SessionListFilter, @@ -80,10 +80,10 @@ "PermissionRequestResult", "PingResponse", "ProviderConfig", - "ResumeSessionConfig", "SectionOverride", + "SectionOverrideAction", + "SectionTransformFn", "SelectionAttachment", - "SessionConfig", "SessionContext", "SessionEvent", "SessionListFilter", diff --git a/python/copilot/client.py b/python/copilot/client.py index 28050088e..e9dd98d35 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -45,6 +45,7 @@ PingResponse, ProviderConfig, ReasoningEffort, + SectionTransformFn, SessionEvent, SessionHooks, SessionLifecycleEvent, @@ -73,6 +74,40 @@ MIN_PROTOCOL_VERSION = 2 +def _extract_transform_callbacks( + system_message: dict | None, +) -> tuple[dict | None, dict[str, SectionTransformFn] | None]: + """Extract function-valued actions from system message config. + + Returns a wire-safe payload (with callable actions replaced by ``"transform"``) + and a dict of transform callbacks keyed by section ID. + """ + if ( + not system_message + or system_message.get("mode") != "customize" + or not system_message.get("sections") + ): + return system_message, None + + callbacks: dict[str, SectionTransformFn] = {} + wire_sections: dict[str, dict] = {} + for section_id, override in system_message["sections"].items(): + if not override: + continue + action = override.get("action") + if callable(action): + callbacks[section_id] = action + wire_sections[section_id] = {"action": "transform"} + else: + wire_sections[section_id] = override + + if not callbacks: + return system_message, None + + wire_payload = {**system_message, "sections": wire_sections} + return wire_payload, callbacks + + def _get_bundled_cli_path() -> str | None: """Get the path to the bundled CLI binary, if available.""" # The binary is bundled in copilot/bin/ within the package @@ -548,8 +583,9 @@ async def create_session( if tool_defs: payload["tools"] = tool_defs - if system_message: - payload["systemMessage"] = system_message + wire_system_message, transform_callbacks = _extract_transform_callbacks(system_message) + if wire_system_message: + payload["systemMessage"] = wire_system_message if available_tools is not None: payload["availableTools"] = available_tools @@ -627,6 +663,8 @@ async def create_session( session._register_user_input_handler(on_user_input_request) if hooks: session._register_hooks(hooks) + if transform_callbacks: + session._register_transform_callbacks(transform_callbacks) if on_event: session.on(on_event) with self._sessions_lock: @@ -760,8 +798,9 @@ async def resume_session( if tool_defs: payload["tools"] = tool_defs - if system_message: - payload["systemMessage"] = system_message + wire_system_message, transform_callbacks = _extract_transform_callbacks(system_message) + if wire_system_message: + payload["systemMessage"] = wire_system_message if available_tools is not None: payload["availableTools"] = available_tools @@ -839,6 +878,8 @@ async def resume_session( session._register_user_input_handler(on_user_input_request) if hooks: session._register_hooks(hooks) + if transform_callbacks: + session._register_transform_callbacks(transform_callbacks) if on_event: session.on(on_event) with self._sessions_lock: @@ -1485,6 +1526,9 @@ def handle_notification(method: str, params: dict): self._client.set_request_handler("permission.request", self._handle_permission_request_v2) self._client.set_request_handler("userInput.request", self._handle_user_input_request) self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke) + self._client.set_request_handler( + "systemMessage.transform", self._handle_system_message_transform + ) # Start listening for messages loop = asyncio.get_running_loop() @@ -1570,6 +1614,9 @@ def handle_notification(method: str, params: dict): self._client.set_request_handler("permission.request", self._handle_permission_request_v2) self._client.set_request_handler("userInput.request", self._handle_user_input_request) self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke) + self._client.set_request_handler( + "systemMessage.transform", self._handle_system_message_transform + ) # Start listening for messages loop = asyncio.get_running_loop() @@ -1630,6 +1677,32 @@ async def _handle_hooks_invoke(self, params: dict) -> dict: output = await session._handle_hooks_invoke(hook_type, input_data) return {"output": output} + async def _handle_system_message_transform(self, params: dict) -> dict: + """ + Handle a systemMessage.transform request from the CLI server. + + Args: + params: The transform parameters from the server. + + Returns: + A dict containing the transformed sections. + + Raises: + ValueError: If the request payload is invalid. + """ + session_id = params.get("sessionId") + sections = params.get("sections") + + if not session_id or not sections: + raise ValueError("invalid systemMessage.transform payload") + + with self._sessions_lock: + session = self._sessions.get(session_id) + if not session: + raise ValueError(f"unknown session {session_id}") + + return await session._handle_system_message_transform(sections) + # ======================================================================== # Protocol v2 backward-compatibility adapters # ======================================================================== diff --git a/python/copilot/session.py b/python/copilot/session.py index 7a8b9f05d..ba00c244f 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -29,6 +29,7 @@ Attachment, PermissionRequest, PermissionRequestResult, + SectionTransformFn, SessionHooks, Tool, ToolHandler, @@ -97,6 +98,8 @@ def __init__(self, session_id: str, client: Any, workspace_path: str | None = No self._user_input_handler_lock = threading.Lock() self._hooks: SessionHooks | None = None self._hooks_lock = threading.Lock() + self._transform_callbacks: dict[str, SectionTransformFn] | None = None + self._transform_callbacks_lock = threading.Lock() self._rpc: SessionRpc | None = None @property @@ -245,7 +248,9 @@ def on(self, handler: Callable[[SessionEvent], None]) -> Callable[[], None]: ... print(f"Assistant: {event.data.content}") ... elif event.type == "session.error": ... print(f"Error: {event.data.message}") + ... >>> unsubscribe = session.on(handle_event) + ... >>> # Later, to stop receiving events: >>> unsubscribe() """ @@ -634,6 +639,60 @@ async def _handle_hooks_invoke(self, hook_type: str, input_data: Any) -> Any: # Hook failed, return None return None + def _register_transform_callbacks(self, callbacks: dict[str, SectionTransformFn] | None) -> None: + """ + Register transform callbacks for system message sections. + + Transform callbacks allow modifying individual sections of the system + prompt at runtime. Each callback receives the current section content + and returns the transformed content. + + Note: + This method is internal. Transform callbacks are typically registered + when creating a session via :meth:`CopilotClient.create_session`. + + Args: + callbacks: A dict mapping section IDs to transform functions, + or None to remove all callbacks. + """ + with self._transform_callbacks_lock: + self._transform_callbacks = callbacks + + async def _handle_system_message_transform( + self, sections: dict[str, dict[str, str]] + ) -> dict[str, dict[str, dict[str, str]]]: + """ + Handle a systemMessage.transform request from the runtime. + + Note: + This method is internal and should not be called directly. + + Args: + sections: A dict mapping section IDs to section data dicts + containing a ``"content"`` key. + + Returns: + A dict with a ``"sections"`` key containing the transformed section data. + """ + with self._transform_callbacks_lock: + callbacks = self._transform_callbacks + + result: dict[str, dict[str, str]] = {} + for section_id, section_data in sections.items(): + content = section_data.get("content", "") + callback = callbacks.get(section_id) if callbacks else None + if callback: + try: + transformed = callback(content) + if inspect.isawaitable(transformed): + transformed = await transformed + result[section_id] = {"content": transformed} + except Exception: # pylint: disable=broad-except + result[section_id] = {"content": content} + else: + result[section_id] = {"content": content} + return {"sections": result} + async def get_messages(self) -> list[SessionEvent]: """ Retrieve all events and messages from this session's history. @@ -728,7 +787,9 @@ async def abort(self) -> None: >>> import asyncio >>> >>> # Start a long-running request - >>> task = asyncio.create_task(session.send("Write a very long story...")) + >>> task = asyncio.create_task( + ... session.send("Write a very long story...") + ... ) >>> >>> # Abort after 5 seconds >>> await asyncio.sleep(5) diff --git a/python/copilot/types.py b/python/copilot/types.py index 9e59445e1..675c0726e 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -247,10 +247,19 @@ class Tool: } +SectionTransformFn = Callable[[str], str | Awaitable[str]] +"""Transform callback: receives current section content, returns new content.""" + +SectionOverrideAction = ( + Literal["replace", "remove", "append", "prepend"] | SectionTransformFn +) +"""Override action: a string literal for static overrides, or a callback for transforms.""" + + class SectionOverride(TypedDict, total=False): """Override operation for a single system prompt section.""" - action: Required[Literal["replace", "remove", "append", "prepend"]] + action: Required[SectionOverrideAction] content: NotRequired[str] diff --git a/python/e2e/test_system_message_transform.py b/python/e2e/test_system_message_transform.py new file mode 100644 index 000000000..acfdea1cf --- /dev/null +++ b/python/e2e/test_system_message_transform.py @@ -0,0 +1,120 @@ +""" +Copyright (c) Microsoft Corporation. + +Tests for system message transform functionality +""" + +import pytest + +from copilot import PermissionHandler + +from .testharness import E2ETestContext +from .testharness.helper import write_file + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestSystemMessageTransform: + async def test_should_invoke_transform_callbacks_with_section_content( + self, ctx: E2ETestContext + ): + """Test that transform callbacks are invoked with the section content""" + identity_contents = [] + tone_contents = [] + + async def identity_transform(content: str) -> str: + identity_contents.append(content) + return content + + async def tone_transform(content: str) -> str: + tone_contents.append(content) + return content + + session = await ctx.client.create_session( + system_message={ + "mode": "customize", + "sections": { + "identity": {"action": identity_transform}, + "tone": {"action": tone_transform}, + }, + }, + on_permission_request=PermissionHandler.approve_all, + ) + + write_file(ctx.work_dir, "test.txt", "Hello transform!") + + await session.send_and_wait("Read the contents of test.txt and tell me what it says") + + # Both transform callbacks should have been invoked + assert len(identity_contents) > 0 + assert len(tone_contents) > 0 + + # Callbacks should have received non-empty content + assert all(len(c) > 0 for c in identity_contents) + assert all(len(c) > 0 for c in tone_contents) + + await session.disconnect() + + async def test_should_apply_transform_modifications_to_section_content( + self, ctx: E2ETestContext + ): + """Test that transform modifications are applied to the section content""" + identity_contents = [] + + async def identity_transform(content: str) -> str: + identity_contents.append(content) + return content + "\nTRANSFORM_MARKER" + + session = await ctx.client.create_session( + system_message={ + "mode": "customize", + "sections": { + "identity": {"action": identity_transform}, + }, + }, + on_permission_request=PermissionHandler.approve_all, + ) + + write_file(ctx.work_dir, "hello.txt", "Hello!") + + await session.send_and_wait("Read the contents of hello.txt") + + # The transform callback should have been invoked + assert len(identity_contents) > 0 + + # The callback should have received content and returned modified content + assert all(len(c) > 0 for c in identity_contents) + + await session.disconnect() + + async def test_should_work_with_static_overrides_and_transforms_together( + self, ctx: E2ETestContext + ): + """Test that static overrides and transforms work together""" + identity_contents = [] + + async def identity_transform(content: str) -> str: + identity_contents.append(content) + return content + + session = await ctx.client.create_session( + system_message={ + "mode": "customize", + "sections": { + "safety": {"action": "remove"}, + "identity": {"action": identity_transform}, + }, + }, + on_permission_request=PermissionHandler.approve_all, + ) + + write_file(ctx.work_dir, "combo.txt", "Combo test!") + + await session.send_and_wait( + "Read the contents of combo.txt and tell me what it says" + ) + + # The transform callback should have been invoked + assert len(identity_contents) > 0 + + await session.disconnect() diff --git a/test/snapshots/system_message_transform/should_apply_transform_modifications_to_section_content.yaml b/test/snapshots/system_message_transform/should_apply_transform_modifications_to_section_content.yaml new file mode 100644 index 000000000..98004f2b0 --- /dev/null +++ b/test/snapshots/system_message_transform/should_apply_transform_modifications_to_section_content.yaml @@ -0,0 +1,33 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of hello.txt + - role: assistant + content: I'll read the hello.txt file for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading hello.txt file"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/hello.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. Hello! + - role: assistant + content: |- + The file hello.txt contains: + ``` + Hello! + ``` diff --git a/test/snapshots/system_message_transform/should_invoke_transform_callbacks_with_section_content.yaml b/test/snapshots/system_message_transform/should_invoke_transform_callbacks_with_section_content.yaml new file mode 100644 index 000000000..631a8eef8 --- /dev/null +++ b/test/snapshots/system_message_transform/should_invoke_transform_callbacks_with_section_content.yaml @@ -0,0 +1,54 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of test.txt and tell me what it says + - role: assistant + content: I'll read the test.txt file for you. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading test.txt file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/test.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of test.txt and tell me what it says + - role: assistant + content: I'll read the test.txt file for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading test.txt file"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/test.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. Hello transform! + - role: assistant + content: |- + The file test.txt contains: + ``` + Hello transform! + ``` diff --git a/test/snapshots/system_message_transform/should_work_with_static_overrides_and_transforms_together.yaml b/test/snapshots/system_message_transform/should_work_with_static_overrides_and_transforms_together.yaml new file mode 100644 index 000000000..9cb515694 --- /dev/null +++ b/test/snapshots/system_message_transform/should_work_with_static_overrides_and_transforms_together.yaml @@ -0,0 +1,50 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of combo.txt and tell me what it says + - role: assistant + content: I'll read the contents of combo.txt for you. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/combo.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of combo.txt and tell me what it says + - role: assistant + content: I'll read the contents of combo.txt for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/combo.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. Combo test! + - role: assistant + content: The file combo.txt contains a single line that says "Combo test!" From f9eab61cc5fb018f1f21538326cacd7746fccd62 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 20 Mar 2026 13:48:49 +0000 Subject: [PATCH 06/12] Formatting Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> --- go/session.go | 24 ++++++++++----------- nodejs/src/client.ts | 4 +--- nodejs/src/types.ts | 7 +++++- python/copilot/session.py | 10 ++++----- python/copilot/types.py | 4 +--- python/e2e/test_system_message_transform.py | 4 +--- 6 files changed, 25 insertions(+), 28 deletions(-) diff --git a/go/session.go b/go/session.go index 83f1de960..6bce7ffab 100644 --- a/go/session.go +++ b/go/session.go @@ -50,18 +50,18 @@ type sessionHandler struct { // }) type Session struct { // SessionID is the unique identifier for this session. - SessionID string - workspacePath string - client *jsonrpc2.Client - handlers []sessionHandler - nextHandlerID uint64 - handlerMutex sync.RWMutex - toolHandlers map[string]ToolHandler - toolHandlersM sync.RWMutex - permissionHandler PermissionHandlerFunc - permissionMux sync.RWMutex - userInputHandler UserInputHandler - userInputMux sync.RWMutex + SessionID string + workspacePath string + client *jsonrpc2.Client + handlers []sessionHandler + nextHandlerID uint64 + handlerMutex sync.RWMutex + toolHandlers map[string]ToolHandler + toolHandlersM sync.RWMutex + permissionHandler PermissionHandlerFunc + permissionMux sync.RWMutex + userInputHandler UserInputHandler + userInputMux sync.RWMutex hooks *SessionHooks hooksMux sync.RWMutex transformCallbacks map[string]SectionTransformFn diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index bb835d032..9b8af3dd1 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -89,9 +89,7 @@ function toJsonSchema(parameters: Tool["parameters"]): Record | * Function-valued actions are replaced with `{ action: "transform" }` for serialization, * and the original callbacks are returned in a separate map. */ -function extractTransformCallbacks( - systemMessage: SessionConfig["systemMessage"] -): { +function extractTransformCallbacks(systemMessage: SessionConfig["systemMessage"]): { wirePayload: SessionConfig["systemMessage"]; transformCallbacks: Map | undefined; } { diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 4bf1d99c3..992dbdb9d 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -319,7 +319,12 @@ export type SectionTransformFn = (currentContent: string) => string | Promise Callable[[], None]: ... print(f"Assistant: {event.data.content}") ... elif event.type == "session.error": ... print(f"Error: {event.data.message}") - ... >>> unsubscribe = session.on(handle_event) - ... >>> # Later, to stop receiving events: >>> unsubscribe() """ @@ -639,7 +637,9 @@ async def _handle_hooks_invoke(self, hook_type: str, input_data: Any) -> Any: # Hook failed, return None return None - def _register_transform_callbacks(self, callbacks: dict[str, SectionTransformFn] | None) -> None: + def _register_transform_callbacks( + self, callbacks: dict[str, SectionTransformFn] | None + ) -> None: """ Register transform callbacks for system message sections. @@ -787,9 +787,7 @@ async def abort(self) -> None: >>> import asyncio >>> >>> # Start a long-running request - >>> task = asyncio.create_task( - ... session.send("Write a very long story...") - ... ) + >>> task = asyncio.create_task(session.send("Write a very long story...")) >>> >>> # Abort after 5 seconds >>> await asyncio.sleep(5) diff --git a/python/copilot/types.py b/python/copilot/types.py index 675c0726e..ef9a4bce4 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -250,9 +250,7 @@ class Tool: SectionTransformFn = Callable[[str], str | Awaitable[str]] """Transform callback: receives current section content, returns new content.""" -SectionOverrideAction = ( - Literal["replace", "remove", "append", "prepend"] | SectionTransformFn -) +SectionOverrideAction = Literal["replace", "remove", "append", "prepend"] | SectionTransformFn """Override action: a string literal for static overrides, or a callback for transforms.""" diff --git a/python/e2e/test_system_message_transform.py b/python/e2e/test_system_message_transform.py index acfdea1cf..ea0ff0277 100644 --- a/python/e2e/test_system_message_transform.py +++ b/python/e2e/test_system_message_transform.py @@ -110,9 +110,7 @@ async def identity_transform(content: str) -> str: write_file(ctx.work_dir, "combo.txt", "Combo test!") - await session.send_and_wait( - "Read the contents of combo.txt and tell me what it says" - ) + await session.send_and_wait("Read the contents of combo.txt and tell me what it says") # The transform callback should have been invoked assert len(identity_contents) > 0 From 16f221b872d25eef1e45fae7b891d1b49315a6ac Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 20 Mar 2026 13:53:18 +0000 Subject: [PATCH 07/12] Add E2E snapshot for customized systemMessage config test Generate the missing snapshot file that the 'should create a session with customized systemMessage config' test requires across all SDK languages (Node, Python, Go, .NET). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ..._with_customized_systemmessage_config.yaml | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 test/snapshots/session/should_create_a_session_with_customized_systemmessage_config.yaml diff --git a/test/snapshots/session/should_create_a_session_with_customized_systemmessage_config.yaml b/test/snapshots/session/should_create_a_session_with_customized_systemmessage_config.yaml new file mode 100644 index 000000000..f3ce077a6 --- /dev/null +++ b/test/snapshots/session/should_create_a_session_with_customized_systemmessage_config.yaml @@ -0,0 +1,35 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Who are you? + - role: assistant + content: >- + I'm **GitHub Copilot CLI**, a terminal assistant built by GitHub. I'm powered by claude-sonnet-4.5 (model ID: + claude-sonnet-4.5). + + + I'm here to help you with software engineering tasks, including: + + - Writing, debugging, and refactoring code + + - Running commands and managing development workflows + + - Exploring codebases and understanding how things work + + - Setting up projects, installing dependencies, and configuring tools + + - Working with Git, testing, and deployment tasks + + - Planning and implementing features + + + I have access to a variety of tools including file operations, shell commands, code search, and specialized + sub-agents for specific tasks. I can work with multiple languages and frameworks, and I'm designed to be + efficient by running tasks in parallel when possible. + + + How can I help you today? From fb6bae8f510aeff6ca5ede3b96b1c06f5c704631 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 20 Mar 2026 13:59:02 +0000 Subject: [PATCH 08/12] Fix Go fmt blank comment line and Python import ordering - Add blank // comment line between doc example and extractTransformCallbacks function doc comment in go/client.go (required by go fmt) - Fix ruff import sorting in python/copilot/__init__.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/client.go | 1 + python/copilot/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/go/client.go b/go/client.go index be70a27d0..22be47ec6 100644 --- a/go/client.go +++ b/go/client.go @@ -482,6 +482,7 @@ func (c *Client) ensureConnected(ctx context.Context) error { // }, // }, // }) +// // extractTransformCallbacks separates transform callbacks from a SystemMessageConfig, // returning a wire-safe config and a map of callbacks (nil if none). func extractTransformCallbacks(config *SystemMessageConfig) (*SystemMessageConfig, map[string]SectionTransformFn) { diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index e467fa887..6a007afa3 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -8,6 +8,7 @@ from .session import CopilotSession from .tools import define_tool from .types import ( + SYSTEM_PROMPT_SECTIONS, Attachment, AzureProviderOptions, BlobAttachment, @@ -30,7 +31,6 @@ PermissionRequestResult, PingResponse, ProviderConfig, - SYSTEM_PROMPT_SECTIONS, SectionOverride, SectionOverrideAction, SectionTransformFn, From 95333dca9c1ed633b4c92091dd9ca6cdee209970 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 20 Mar 2026 14:05:57 +0000 Subject: [PATCH 09/12] Fix Python ty type error in session transform handler Use str() to ensure transform callback result is typed as str, fixing the invalid-assignment error from ty type checker at session.py:689. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/copilot/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/copilot/session.py b/python/copilot/session.py index 28fee76d2..29421724c 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -686,7 +686,7 @@ async def _handle_system_message_transform( transformed = callback(content) if inspect.isawaitable(transformed): transformed = await transformed - result[section_id] = {"content": transformed} + result[section_id] = {"content": str(transformed)} except Exception: # pylint: disable=broad-except result[section_id] = {"content": content} else: From 33218962a3af2f92c5c256267e402a6b1c1462d7 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 20 Mar 2026 14:13:08 +0000 Subject: [PATCH 10/12] Fix Python E2E test to use keyword args for create_session The create_session() method was refactored to keyword-only params. Update the customized systemMessage test to use keyword arguments instead of a positional dict, and fix send_and_wait() call to pass prompt as a positional string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/e2e/test_session.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index d0b80fadc..04f0b448e 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -88,20 +88,18 @@ async def test_should_create_a_session_with_customized_systemMessage_config( custom_tone = "Respond in a warm, professional tone. Be thorough in explanations." appended_content = "Always mention quarterly earnings." session = await ctx.client.create_session( - { - "system_message": { - "mode": "customize", - "sections": { - "tone": {"action": "replace", "content": custom_tone}, - "code_change_rules": {"action": "remove"}, - }, - "content": appended_content, + on_permission_request=PermissionHandler.approve_all, + system_message={ + "mode": "customize", + "sections": { + "tone": {"action": "replace", "content": custom_tone}, + "code_change_rules": {"action": "remove"}, }, - "on_permission_request": PermissionHandler.approve_all, - } + "content": appended_content, + }, ) - assistant_message = await session.send_and_wait({"prompt": "Who are you?"}) + assistant_message = await session.send_and_wait("Who are you?") assert assistant_message is not None # Validate the system message sent to the model From 2063bc835da09ad116fc01ac60b77d3a7eeb6813 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 20 Mar 2026 15:36:44 +0000 Subject: [PATCH 11/12] Assert transform result in system message via HTTP traffic The 'should apply transform modifications' tests previously only verified that the transform callback was invoked, not that the transformed content actually reached the model. Now all 4 SDKs assert that TRANSFORM_MARKER appears in the system message captured from HTTP traffic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/SystemMessageTransformTests.cs | 11 ++++--- .../e2e/system_message_transform_test.go | 32 +++++++++---------- .../test/e2e/system_message_transform.test.ts | 26 ++++++++------- python/e2e/test_system_message_transform.py | 19 +++++++---- 4 files changed, 47 insertions(+), 41 deletions(-) diff --git a/dotnet/test/SystemMessageTransformTests.cs b/dotnet/test/SystemMessageTransformTests.cs index 57fab5770..cdddc5a79 100644 --- a/dotnet/test/SystemMessageTransformTests.cs +++ b/dotnet/test/SystemMessageTransformTests.cs @@ -62,8 +62,6 @@ await session.SendAsync(new MessageOptions [Fact] public async Task Should_Apply_Transform_Modifications_To_Section_Content() { - var transformCallbackInvoked = false; - var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, @@ -76,8 +74,7 @@ public async Task Should_Apply_Transform_Modifications_To_Section_Content() { Transform = async (content) => { - transformCallbackInvoked = true; - return content + "TRANSFORM_MARKER"; + return content + "\nAlways end your reply with TRANSFORM_MARKER"; } } } @@ -93,7 +90,11 @@ await session.SendAsync(new MessageOptions await TestHelper.GetFinalAssistantMessageAsync(session); - Assert.True(transformCallbackInvoked, "Expected identity transform callback to be invoked"); + // Verify the transform result was actually applied to the system message + var traffic = await Ctx.GetExchangesAsync(); + Assert.NotEmpty(traffic); + var systemMessage = GetSystemMessage(traffic[0]); + Assert.Contains("TRANSFORM_MARKER", systemMessage); } [Fact] diff --git a/go/internal/e2e/system_message_transform_test.go b/go/internal/e2e/system_message_transform_test.go index d55c2d7d2..2d62b01cf 100644 --- a/go/internal/e2e/system_message_transform_test.go +++ b/go/internal/e2e/system_message_transform_test.go @@ -6,6 +6,7 @@ package e2e import ( "os" "path/filepath" + "strings" "sync" "testing" @@ -90,10 +91,6 @@ func TestSystemMessageTransform(t *testing.T) { t.Run("should_apply_transform_modifications_to_section_content", func(t *testing.T) { ctx.ConfigureForTest(t) - var receivedContent string - var mu sync.Mutex - transformCalled := false - session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, SystemMessage: &copilot.SystemMessageConfig{ @@ -101,11 +98,7 @@ func TestSystemMessageTransform(t *testing.T) { Sections: map[string]copilot.SectionOverride{ "identity": { Transform: func(currentContent string) (string, error) { - mu.Lock() - transformCalled = true - receivedContent = currentContent - mu.Unlock() - return currentContent + "\nTRANSFORM_MARKER", nil + return currentContent + "\nAlways end your reply with TRANSFORM_MARKER", nil }, }, }, @@ -121,22 +114,27 @@ func TestSystemMessageTransform(t *testing.T) { t.Fatalf("Failed to write test file: %v", err) } - _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + assistantMessage, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ Prompt: "Read the contents of hello.txt", }) if err != nil { t.Fatalf("Failed to send message: %v", err) } - mu.Lock() - defer mu.Unlock() - - if !transformCalled { - t.Error("Expected transform callback to be invoked") + // Verify the transform result was actually applied to the system message + traffic, err := ctx.GetExchanges() + if err != nil { + t.Fatalf("Failed to get exchanges: %v", err) + } + if len(traffic) == 0 { + t.Fatal("Expected at least one exchange") } - if receivedContent == "" { - t.Error("Expected transform to receive non-empty content") + systemMessage := getSystemMessage(traffic[0]) + if !strings.Contains(systemMessage, "TRANSFORM_MARKER") { + t.Errorf("Expected system message to contain TRANSFORM_MARKER, got %q", systemMessage) } + + _ = assistantMessage }) t.Run("should_work_with_static_overrides_and_transforms_together", func(t *testing.T) { diff --git a/nodejs/test/e2e/system_message_transform.test.ts b/nodejs/test/e2e/system_message_transform.test.ts index 20211ae98..ef37c39e9 100644 --- a/nodejs/test/e2e/system_message_transform.test.ts +++ b/nodejs/test/e2e/system_message_transform.test.ts @@ -5,11 +5,12 @@ import { writeFile } from "fs/promises"; import { join } from "path"; import { describe, expect, it } from "vitest"; +import { ParsedHttpExchange } from "../../../test/harness/replayingCapiProxy.js"; import { approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("System message transform", async () => { - const { copilotClient: client, workDir } = await createSdkTestContext(); + const { copilotClient: client, openAiEndpoint, workDir } = await createSdkTestContext(); it("should invoke transform callbacks with section content", async () => { const transformedSections: Record = {}; @@ -53,9 +54,6 @@ describe("System message transform", async () => { }); it("should apply transform modifications to section content", async () => { - let originalContent = ""; - let transformedContent = ""; - const session = await client.createSession({ onPermissionRequest: approveAll, systemMessage: { @@ -63,10 +61,7 @@ describe("System message transform", async () => { sections: { identity: { action: (content: string) => { - originalContent = content; - // Append a custom instruction via transform - transformedContent = content + "\nTRANSFORM_MARKER"; - return transformedContent; + return content + "\nTRANSFORM_MARKER"; }, }, }, @@ -79,10 +74,10 @@ describe("System message transform", async () => { prompt: "Read the contents of hello.txt", }); - // Verify the transform callback was invoked and modified the content - expect(originalContent.length).toBeGreaterThan(0); - expect(transformedContent).toContain("TRANSFORM_MARKER"); - expect(transformedContent).toContain(originalContent); + // Verify the transform result was actually applied to the system message + const traffic = await openAiEndpoint.getExchanges(); + const systemMessage = getSystemMessage(traffic[0]); + expect(systemMessage).toContain("TRANSFORM_MARKER"); await session.disconnect(); }); @@ -121,3 +116,10 @@ describe("System message transform", async () => { await session.disconnect(); }); }); + +function getSystemMessage(exchange: ParsedHttpExchange): string | undefined { + const systemMessage = exchange.request.messages.find((m) => m.role === "system") as + | { role: "system"; content: string } + | undefined; + return systemMessage?.content; +} diff --git a/python/e2e/test_system_message_transform.py b/python/e2e/test_system_message_transform.py index ea0ff0277..9ae170637 100644 --- a/python/e2e/test_system_message_transform.py +++ b/python/e2e/test_system_message_transform.py @@ -59,10 +59,8 @@ async def test_should_apply_transform_modifications_to_section_content( self, ctx: E2ETestContext ): """Test that transform modifications are applied to the section content""" - identity_contents = [] async def identity_transform(content: str) -> str: - identity_contents.append(content) return content + "\nTRANSFORM_MARKER" session = await ctx.client.create_session( @@ -79,11 +77,10 @@ async def identity_transform(content: str) -> str: await session.send_and_wait("Read the contents of hello.txt") - # The transform callback should have been invoked - assert len(identity_contents) > 0 - - # The callback should have received content and returned modified content - assert all(len(c) > 0 for c in identity_contents) + # Verify the transform result was actually applied to the system message + traffic = await ctx.get_exchanges() + system_message = _get_system_message(traffic[0]) + assert "TRANSFORM_MARKER" in system_message await session.disconnect() @@ -116,3 +113,11 @@ async def identity_transform(content: str) -> str: assert len(identity_contents) > 0 await session.disconnect() + + +def _get_system_message(exchange: dict) -> str: + messages = exchange.get("request", {}).get("messages", []) + for msg in messages: + if msg.get("role") == "system": + return msg.get("content", "") + return "" From 79c5e59c67a1e0c95d0d2b4f3f5350f83dce3470 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 20 Mar 2026 15:49:25 +0000 Subject: [PATCH 12/12] Fix Go transform JSON serialization: add json tags for content field The systemMessageTransformRequest and systemMessageTransformResponse used anonymous structs without json tags, causing Content to serialize as uppercase 'Content' instead of lowercase 'content'. The CLI expects lowercase, so transform results were silently ignored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/session.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/go/session.go b/go/session.go index 6bce7ffab..3a94a818e 100644 --- a/go/session.go +++ b/go/session.go @@ -458,23 +458,27 @@ func (s *Session) registerTransformCallbacks(callbacks map[string]SectionTransfo s.transformCallbacks = callbacks } +type systemMessageTransformSection struct { + Content string `json:"content"` +} + type systemMessageTransformRequest struct { - SessionID string `json:"sessionId"` - Sections map[string]struct{ Content string } `json:"sections"` + SessionID string `json:"sessionId"` + Sections map[string]systemMessageTransformSection `json:"sections"` } type systemMessageTransformResponse struct { - Sections map[string]struct{ Content string } `json:"sections"` + Sections map[string]systemMessageTransformSection `json:"sections"` } // handleSystemMessageTransform handles a system message transform request from the Copilot CLI. // This is an internal method called by the SDK when the CLI requests section transforms. -func (s *Session) handleSystemMessageTransform(sections map[string]struct{ Content string }) (systemMessageTransformResponse, error) { +func (s *Session) handleSystemMessageTransform(sections map[string]systemMessageTransformSection) (systemMessageTransformResponse, error) { s.transformMu.Lock() callbacks := s.transformCallbacks s.transformMu.Unlock() - result := make(map[string]struct{ Content string }) + result := make(map[string]systemMessageTransformSection) for sectionID, data := range sections { var callback SectionTransformFn if callbacks != nil { @@ -483,12 +487,12 @@ func (s *Session) handleSystemMessageTransform(sections map[string]struct{ Conte if callback != nil { transformed, err := callback(data.Content) if err != nil { - result[sectionID] = struct{ Content string }{Content: data.Content} + result[sectionID] = systemMessageTransformSection{Content: data.Content} } else { - result[sectionID] = struct{ Content string }{Content: transformed} + result[sectionID] = systemMessageTransformSection{Content: transformed} } } else { - result[sectionID] = struct{ Content string }{Content: data.Content} + result[sectionID] = systemMessageTransformSection{Content: data.Content} } } return systemMessageTransformResponse{Sections: result}, nil