Skip to content

Commit 4102bd1

Browse files
Add system prompt section transform support across all SDKs
Add a new 'transform' action for system prompt section customization that enables read-then-write mutations via callbacks. When a section is configured with a transform, the runtime sends the current rendered content to the SDK via a 'systemMessage.transform' JSON-RPC callback, and the SDK dispatches to per-section user-defined callbacks. Changes per language: - TypeScript: SectionTransformFn type, extractTransformCallbacks helper, RPC handler, session callback storage - Python: SectionTransformFn callable type, _extract_transform_callbacks helper, RPC handler, session callback storage (sync+async support) - Go: SectionTransformFn type, Transform field on SectionOverride, extractTransformCallbacks helper, RPC handler - .NET: Transform delegate on SectionOverride, ExtractTransformCallbacks helper, RPC handler Also fixes two pre-existing build errors: - Go: Casing mismatch in generated_rpc.go (SessionMcp vs SessionMCP) - .NET: Missing 'url' parameter in Session.LogAsync wrapper Includes E2E tests and shared snapshot files for all 4 languages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 41bb55a commit 4102bd1

21 files changed

+1277
-27
lines changed

dotnet/src/Client.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,11 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
428428
{
429429
session.On(config.OnEvent);
430430
}
431+
var transformCallbacks = ExtractTransformCallbacks(config.SystemMessage);
432+
if (transformCallbacks != null)
433+
{
434+
session.RegisterTransformCallbacks(transformCallbacks);
435+
}
431436
_sessions[sessionId] = session;
432437

433438
try
@@ -536,6 +541,11 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
536541
{
537542
session.On(config.OnEvent);
538543
}
544+
var transformCallbacks = ExtractTransformCallbacks(config.SystemMessage);
545+
if (transformCallbacks != null)
546+
{
547+
session.RegisterTransformCallbacks(transformCallbacks);
548+
}
539549
_sessions[sessionId] = session;
540550

541551
try
@@ -1222,6 +1232,7 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
12221232
rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequestV2);
12231233
rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest);
12241234
rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke);
1235+
rpc.AddLocalRpcMethod("systemMessage.transform", handler.OnSystemMessageTransform);
12251236
rpc.StartListening();
12261237

12271238
// Transition state to Disconnected if the JSON-RPC connection drops
@@ -1271,6 +1282,36 @@ private static JsonSerializerOptions CreateSerializerOptions()
12711282
return _sessions.TryGetValue(sessionId, out var session) ? session : null;
12721283
}
12731284

1285+
/// <summary>
1286+
/// Extracts transform callbacks from section overrides so they can be registered on the session.
1287+
/// The <see cref="SectionOverride.Transform"/> property is <c>[JsonIgnore]</c> and won't be
1288+
/// serialized over the wire; this method captures the delegates before the RPC call.
1289+
/// </summary>
1290+
private static Dictionary<string, Func<string, Task<string>>>? ExtractTransformCallbacks(SystemMessageConfig? config)
1291+
{
1292+
if (config?.Sections == null)
1293+
return null;
1294+
1295+
Dictionary<string, Func<string, Task<string>>>? callbacks = null;
1296+
1297+
foreach (var (sectionName, sectionOverride) in config.Sections)
1298+
{
1299+
if (sectionOverride.Action == SectionOverrideAction.Transform)
1300+
{
1301+
if (sectionOverride.Transform == null)
1302+
{
1303+
throw new ArgumentException(
1304+
$"Section '{sectionName}' has action 'Transform' but no Transform callback was provided.");
1305+
}
1306+
1307+
callbacks ??= new Dictionary<string, Func<string, Task<string>>>();
1308+
callbacks[sectionName] = sectionOverride.Transform;
1309+
}
1310+
}
1311+
1312+
return callbacks;
1313+
}
1314+
12741315
/// <summary>
12751316
/// Disposes the <see cref="CopilotClient"/> synchronously.
12761317
/// </summary>
@@ -1350,6 +1391,13 @@ public async Task<HooksInvokeResponse> OnHooksInvoke(string sessionId, string ho
13501391
return new HooksInvokeResponse(output);
13511392
}
13521393

1394+
public async Task<SystemMessageTransformResponse> OnSystemMessageTransform(string sessionId, string sectionName, string content)
1395+
{
1396+
var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}");
1397+
var result = await session.HandleSystemMessageTransformAsync(sectionName, content);
1398+
return new SystemMessageTransformResponse(result);
1399+
}
1400+
13531401
// Protocol v2 backward-compatibility adapters
13541402

13551403
public async Task<ToolCallResponseV2> OnToolCallV2(string sessionId,
@@ -1686,6 +1734,7 @@ private static LogLevel MapLevel(TraceEventType eventType)
16861734
[JsonSerializable(typeof(SectionOverride))]
16871735
[JsonSerializable(typeof(SessionMetadata))]
16881736
[JsonSerializable(typeof(SystemMessageConfig))]
1737+
[JsonSerializable(typeof(SystemMessageTransformResponse))]
16891738
[JsonSerializable(typeof(ToolCallResponseV2))]
16901739
[JsonSerializable(typeof(ToolDefinition))]
16911740
[JsonSerializable(typeof(ToolResultAIContent))]

dotnet/src/Session.cs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ public sealed partial class CopilotSession : IAsyncDisposable
6565

6666
private SessionHooks? _hooks;
6767
private readonly SemaphoreSlim _hooksLock = new(1, 1);
68+
private Dictionary<string, Func<string, Task<string>>>? _transformCallbacks;
6869
private SessionRpc? _sessionRpc;
6970
private int _isDisposed;
7071

@@ -588,6 +589,31 @@ internal void RegisterHooks(SessionHooks hooks)
588589
}
589590
}
590591

592+
/// <summary>
593+
/// Registers transform callbacks for system message sections.
594+
/// </summary>
595+
/// <param name="callbacks">Dictionary mapping section names to transform functions.</param>
596+
internal void RegisterTransformCallbacks(Dictionary<string, Func<string, Task<string>>> callbacks)
597+
{
598+
_transformCallbacks = callbacks;
599+
}
600+
601+
/// <summary>
602+
/// Handles a <c>systemMessage.transform</c> request from the Copilot CLI.
603+
/// </summary>
604+
/// <param name="sectionName">The section name to transform.</param>
605+
/// <param name="content">The current section content.</param>
606+
/// <returns>A task that resolves with the transformed content.</returns>
607+
internal async Task<string> HandleSystemMessageTransformAsync(string sectionName, string content)
608+
{
609+
if (_transformCallbacks == null || !_transformCallbacks.TryGetValue(sectionName, out var callback))
610+
{
611+
throw new InvalidOperationException($"No transform callback registered for section '{sectionName}'");
612+
}
613+
614+
return await callback(content);
615+
}
616+
591617
/// <summary>
592618
/// Handles a hook invocation from the Copilot CLI.
593619
/// </summary>
@@ -749,6 +775,7 @@ public Task SetModelAsync(string model, CancellationToken cancellationToken = de
749775
/// <param name="message">The message to log.</param>
750776
/// <param name="level">Log level (default: info).</param>
751777
/// <param name="ephemeral">When <c>true</c>, the message is not persisted to disk.</param>
778+
/// <param name="url">Optional URL to associate with the log entry.</param>
752779
/// <param name="cancellationToken">Optional cancellation token.</param>
753780
/// <example>
754781
/// <code>
@@ -758,9 +785,9 @@ public Task SetModelAsync(string model, CancellationToken cancellationToken = de
758785
/// await session.LogAsync("Temporary status", ephemeral: true);
759786
/// </code>
760787
/// </example>
761-
public async Task LogAsync(string message, SessionLogRequestLevel? level = null, bool? ephemeral = null, CancellationToken cancellationToken = default)
788+
public async Task LogAsync(string message, SessionLogRequestLevel? level = null, bool? ephemeral = null, string? url = null, CancellationToken cancellationToken = default)
762789
{
763-
await Rpc.LogAsync(message, level, ephemeral, cancellationToken);
790+
await Rpc.LogAsync(message, level, ephemeral, url, cancellationToken);
764791
}
765792

766793
/// <summary>

dotnet/src/Types.cs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -991,7 +991,10 @@ public enum SectionOverrideAction
991991
Append,
992992
/// <summary>Prepend content before the existing section.</summary>
993993
[JsonStringEnumMemberName("prepend")]
994-
Prepend
994+
Prepend,
995+
/// <summary>Transform the section content via a callback.</summary>
996+
[JsonStringEnumMemberName("transform")]
997+
Transform
995998
}
996999

9971000
/// <summary>
@@ -1008,6 +1011,13 @@ public class SectionOverride
10081011
/// Content for the override. Optional for all actions. Ignored for remove.
10091012
/// </summary>
10101013
public string? Content { get; set; }
1014+
1015+
/// <summary>
1016+
/// Callback that transforms the section content. Used with <see cref="SectionOverrideAction.Transform"/>.
1017+
/// This delegate is not serialized; it is registered as an RPC callback.
1018+
/// </summary>
1019+
[JsonIgnore]
1020+
public Func<string, Task<string>>? Transform { get; set; }
10111021
}
10121022

10131023
/// <summary>
@@ -1060,6 +1070,20 @@ public class SystemMessageConfig
10601070
public Dictionary<string, SectionOverride>? Sections { get; set; }
10611071
}
10621072

1073+
/// <summary>
1074+
/// RPC request for the <c>systemMessage.transform</c> callback.
1075+
/// </summary>
1076+
internal record SystemMessageTransformRequest(
1077+
string SessionId,
1078+
string SectionName,
1079+
string Content);
1080+
1081+
/// <summary>
1082+
/// RPC response for the <c>systemMessage.transform</c> callback.
1083+
/// </summary>
1084+
internal record SystemMessageTransformResponse(
1085+
string Content);
1086+
10631087
/// <summary>
10641088
/// Configuration for a custom model provider.
10651089
/// </summary>
@@ -2139,6 +2163,8 @@ public class SetForegroundSessionResponse
21392163
[JsonSerializable(typeof(SessionMetadata))]
21402164
[JsonSerializable(typeof(SetForegroundSessionResponse))]
21412165
[JsonSerializable(typeof(SystemMessageConfig))]
2166+
[JsonSerializable(typeof(SystemMessageTransformRequest))]
2167+
[JsonSerializable(typeof(SystemMessageTransformResponse))]
21422168
[JsonSerializable(typeof(ToolBinaryResult))]
21432169
[JsonSerializable(typeof(ToolInvocation))]
21442170
[JsonSerializable(typeof(ToolResultObject))]
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using GitHub.Copilot.SDK.Test.Harness;
6+
using Xunit;
7+
using Xunit.Abstractions;
8+
9+
namespace GitHub.Copilot.SDK.Test;
10+
11+
public class SystemMessageTransformTests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "system_message_transform", output)
12+
{
13+
[Fact]
14+
public async Task Should_Invoke_Transform_Callbacks_With_Section_Content()
15+
{
16+
var identityCallbackInvoked = false;
17+
var toneCallbackInvoked = false;
18+
19+
var session = await CreateSessionAsync(new SessionConfig
20+
{
21+
OnPermissionRequest = PermissionHandler.ApproveAll,
22+
SystemMessage = new SystemMessageConfig
23+
{
24+
Mode = SystemMessageMode.Customize,
25+
Sections = new Dictionary<string, SectionOverride>
26+
{
27+
["identity"] = new SectionOverride
28+
{
29+
Transform = async (content) =>
30+
{
31+
Assert.False(string.IsNullOrEmpty(content));
32+
identityCallbackInvoked = true;
33+
return content;
34+
}
35+
},
36+
["tone"] = new SectionOverride
37+
{
38+
Transform = async (content) =>
39+
{
40+
Assert.False(string.IsNullOrEmpty(content));
41+
toneCallbackInvoked = true;
42+
return content;
43+
}
44+
}
45+
}
46+
}
47+
});
48+
49+
await File.WriteAllTextAsync(Path.Combine(Ctx.WorkDir, "test.txt"), "Hello transform!");
50+
51+
await session.SendAsync(new MessageOptions
52+
{
53+
Prompt = "Read the contents of test.txt and tell me what it says"
54+
});
55+
56+
await TestHelper.GetFinalAssistantMessageAsync(session);
57+
58+
Assert.True(identityCallbackInvoked, "Expected identity transform callback to be invoked");
59+
Assert.True(toneCallbackInvoked, "Expected tone transform callback to be invoked");
60+
}
61+
62+
[Fact]
63+
public async Task Should_Apply_Transform_Modifications_To_Section_Content()
64+
{
65+
var transformCallbackInvoked = false;
66+
67+
var session = await CreateSessionAsync(new SessionConfig
68+
{
69+
OnPermissionRequest = PermissionHandler.ApproveAll,
70+
SystemMessage = new SystemMessageConfig
71+
{
72+
Mode = SystemMessageMode.Customize,
73+
Sections = new Dictionary<string, SectionOverride>
74+
{
75+
["identity"] = new SectionOverride
76+
{
77+
Transform = async (content) =>
78+
{
79+
transformCallbackInvoked = true;
80+
return content + "TRANSFORM_MARKER";
81+
}
82+
}
83+
}
84+
}
85+
});
86+
87+
await File.WriteAllTextAsync(Path.Combine(Ctx.WorkDir, "hello.txt"), "Hello!");
88+
89+
await session.SendAsync(new MessageOptions
90+
{
91+
Prompt = "Read the contents of hello.txt"
92+
});
93+
94+
await TestHelper.GetFinalAssistantMessageAsync(session);
95+
96+
Assert.True(transformCallbackInvoked, "Expected identity transform callback to be invoked");
97+
}
98+
99+
[Fact]
100+
public async Task Should_Work_With_Static_Overrides_And_Transforms_Together()
101+
{
102+
var transformCallbackInvoked = false;
103+
104+
var session = await CreateSessionAsync(new SessionConfig
105+
{
106+
OnPermissionRequest = PermissionHandler.ApproveAll,
107+
SystemMessage = new SystemMessageConfig
108+
{
109+
Mode = SystemMessageMode.Customize,
110+
Sections = new Dictionary<string, SectionOverride>
111+
{
112+
["safety"] = new SectionOverride
113+
{
114+
Action = SectionOverrideAction.Remove
115+
},
116+
["identity"] = new SectionOverride
117+
{
118+
Transform = async (content) =>
119+
{
120+
transformCallbackInvoked = true;
121+
return content;
122+
}
123+
}
124+
}
125+
}
126+
});
127+
128+
await File.WriteAllTextAsync(Path.Combine(Ctx.WorkDir, "combo.txt"), "Combo test!");
129+
130+
await session.SendAsync(new MessageOptions
131+
{
132+
Prompt = "Read the contents of combo.txt and tell me what it says"
133+
});
134+
135+
await TestHelper.GetFinalAssistantMessageAsync(session);
136+
137+
Assert.True(transformCallbackInvoked, "Expected identity transform callback to be invoked");
138+
}
139+
}

0 commit comments

Comments
 (0)