From 45aea30aff76c22e84ae2c60079a5413f5cd077d Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 14 May 2026 12:48:48 +0100 Subject: [PATCH 1/7] Add DebuggerWelcomeMessage to job message and config Add a nullable string DebuggerWelcomeMessage property to AgentJobRequestMessage (from run-service) and thread it through DebuggerConfig so the runner can use it when a debugger client connects. Three-state semantics: - null (absent): use default help text - empty string: suppress welcome message - non-empty: display the provided message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DebuggerConfig.cs | 11 +++++++++-- src/Runner.Worker/ExecutionContext.cs | 2 +- .../DTPipelines/Pipelines/AgentJobRequestMessage.cs | 12 ++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/Runner.Worker/Dap/DebuggerConfig.cs b/src/Runner.Worker/Dap/DebuggerConfig.cs index df139a15c18..4d22091b93e 100644 --- a/src/Runner.Worker/Dap/DebuggerConfig.cs +++ b/src/Runner.Worker/Dap/DebuggerConfig.cs @@ -1,4 +1,4 @@ -using GitHub.DistributedTask.Pipelines; +using GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Worker.Dap { @@ -8,10 +8,11 @@ namespace GitHub.Runner.Worker.Dap /// public sealed class DebuggerConfig { - public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel) + public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel, string welcomeMessage = null) { Enabled = enabled; Tunnel = tunnel; + WelcomeMessage = welcomeMessage; } /// Whether the debugger is enabled for this job. @@ -23,6 +24,12 @@ public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel) /// public DebuggerTunnelInfo Tunnel { get; } + /// + /// Optional welcome message for the debugger console. + /// Null = show default help, empty = show nothing, non-empty = show as-is. + /// + public string WelcomeMessage { get; } + /// Whether the tunnel configuration is complete and valid. public bool HasValidTunnel => Tunnel != null && !string.IsNullOrEmpty(Tunnel.TunnelId) diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index f072335b440..4dcfd1354bf 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -970,7 +970,7 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation Global.WriteDebug = Global.Variables.Step_Debug ?? false; // Debugger enabled flag (from acquire response). - Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel); + Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel, message.DebuggerWelcomeMessage); // Hook up JobServerQueueThrottling event, we will log warning on server tarpit. _jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived; diff --git a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs index 96cf07a71c2..92d7de45b9e 100644 --- a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs +++ b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs @@ -267,6 +267,18 @@ public DebuggerTunnelInfo DebuggerTunnel set; } + /// + /// Optional welcome message to show in the debugger console when a + /// client connects. Null means "use the default help text", empty + /// string means "show nothing", any other value is displayed as-is. + /// + [DataMember(EmitDefaultValue = false)] + public string DebuggerWelcomeMessage + { + get; + set; + } + /// /// Gets the workflow-level action dependencies (lockfile entries) /// From 6f69411b95020f60082b74b47b05d0f1c1d98c10 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 14 May 2026 12:48:56 +0100 Subject: [PATCH 2/7] Send welcome message on debugger configurationDone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the DAP configurationDone handshake completes, send a console output event with the welcome message. Behaviour is gated behind the actions_runner_debugger_welcome_message feature flag and respects the three-state WelcomeMessage from DebuggerConfig: - null → default help text (DapReplParser.GetGeneralHelp()) - "" → no message - value → custom message from run-service A _welcomeMessageSent guard prevents duplicate messages on repeated configurationDone requests or reconnections. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapDebugger.cs | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 3b3ec7cbfaf..6823429d72c 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -63,6 +63,7 @@ public sealed class DapDebugger : RunnerService, IDapDebugger private volatile DapSessionState _state = DapSessionState.NotStarted; private CancellationTokenRegistration? _cancellationRegistration; private bool _isFirstStep = true; + private bool _welcomeMessageSent; // Dev Tunnel relay host for remote debugging private TunnelRelayTunnelHost _tunnelRelayHost; @@ -490,6 +491,11 @@ internal async Task HandleMessageAsync(string messageJson, CancellationToken can }); Trace.Info("Sent initialized event"); } + + if (request.Command == "configurationDone") + { + SendWelcomeMessage(); + } } catch (Exception ex) { @@ -818,6 +824,35 @@ private void SendOutput(string category, string text) }); } + internal void SendWelcomeMessage() + { + if (_welcomeMessageSent) + { + return; + } + _welcomeMessageSent = true; + + var welcomeMessage = _jobContext?.Global?.Debugger?.WelcomeMessage; + + // null → default help text + // "" → no message + // other → custom message + if (welcomeMessage == null) + { + SendOutput("console", DapReplParser.GetGeneralHelp()); + Trace.Info("Sent default welcome message"); + } + else if (welcomeMessage.Length > 0) + { + SendOutput("console", welcomeMessage); + Trace.Info("Sent custom welcome message"); + } + else + { + Trace.Info("Welcome message is empty, skipping"); + } + } + internal async Task OnStepStartingAsync(IStep step, bool isFirstStep) { bool pauseOnNextStep; From ff6d5680c671c20ff326e71e8237b7fddf87e5dd Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 14 May 2026 12:49:05 +0100 Subject: [PATCH 3/7] Add tests for debugger welcome message DapDebuggerL0: - Default help shown when WelcomeMessage is null and flag enabled - Custom message shown when WelcomeMessage is non-empty - No message when WelcomeMessage is empty string - No message when feature flag is disabled - Welcome message sent only once per session AgentJobRequestMessageL0: - DebuggerWelcomeMessage deserializes as null when absent - Empty string preserved on deserialization - Custom string preserved on deserialization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Sdk/RSWebApi/AgentJobRequestMessageL0.cs | 74 ++++++- src/Test/L0/Worker/DapDebuggerL0.cs | 183 +++++++++++++++++- 2 files changed, 247 insertions(+), 10 deletions(-) diff --git a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs index 1a451d28f12..85c132e0140 100644 --- a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs +++ b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Runtime.Serialization.Json; using System.Text; @@ -17,13 +17,13 @@ public void VerifyEnableDebuggerDeserialization_WithTrue() // Arrange var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); string jsonWithEnabledDebugger = DoubleQuotify("{'EnableDebugger': true}"); - + // Act using var stream = new MemoryStream(); stream.Write(Encoding.UTF8.GetBytes(jsonWithEnabledDebugger)); stream.Position = 0; var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; - + // Assert Assert.NotNull(recoveredMessage); Assert.True(recoveredMessage.EnableDebugger, "EnableDebugger should be true when JSON contains 'EnableDebugger': true"); @@ -37,13 +37,13 @@ public void VerifyEnableDebuggerDeserialization_DefaultToFalse() // Arrange var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); string jsonWithoutDebugger = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}"); - + // Act using var stream = new MemoryStream(); stream.Write(Encoding.UTF8.GetBytes(jsonWithoutDebugger)); stream.Position = 0; var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; - + // Assert Assert.NotNull(recoveredMessage); Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should default to false when JSON field is absent"); @@ -57,13 +57,13 @@ public void VerifyEnableDebuggerDeserialization_WithFalse() // Arrange var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); string jsonWithDisabledDebugger = DoubleQuotify("{'EnableDebugger': false}"); - + // Act using var stream = new MemoryStream(); stream.Write(Encoding.UTF8.GetBytes(jsonWithDisabledDebugger)); stream.Position = 0; var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; - + // Assert Assert.NotNull(recoveredMessage); Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false"); @@ -161,6 +161,66 @@ public void VerifyActionsDependenciesDeserialization_DefaultsToEmpty() Assert.Empty(recoveredMessage.ActionsDependencies); } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyDebuggerWelcomeMessageDeserialization_WhenAbsent() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string json = DoubleQuotify("{'EnableDebugger': true}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(json)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert — absent key should deserialize as null + Assert.NotNull(recoveredMessage); + Assert.Null(recoveredMessage.DebuggerWelcomeMessage); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyDebuggerWelcomeMessageDeserialization_WhenEmpty() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string json = DoubleQuotify("{'DebuggerWelcomeMessage': ''}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(json)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert — empty string is preserved + Assert.NotNull(recoveredMessage); + Assert.Equal("", recoveredMessage.DebuggerWelcomeMessage); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyDebuggerWelcomeMessageDeserialization_WithCustomMessage() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string json = DoubleQuotify("{'DebuggerWelcomeMessage': 'Welcome to debugging!'}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(json)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert — custom message is preserved + Assert.NotNull(recoveredMessage); + Assert.Equal("Welcome to debugging!", recoveredMessage.DebuggerWelcomeMessage); + } + private static string DoubleQuotify(string text) { return text.Replace('\'', '"'); diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index f1a29306ff1..2ebeb06b5fe 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Net; using System.Net.Sockets; @@ -236,7 +236,7 @@ private static async Task ReadWebSocketDataUntilAsync(WebSocket client, } } - private static Mock CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null) + private static Mock CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null, string welcomeMessage = null) { var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo { @@ -245,7 +245,7 @@ private static Mock CreateJobContextWithTunnel(CancellationTo HostToken = "test-token", Port = port }; - var debuggerConfig = new DebuggerConfig(true, tunnel); + var debuggerConfig = new DebuggerConfig(true, tunnel, welcomeMessage); var jobContext = new Mock(); jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken); jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig }); @@ -742,6 +742,8 @@ public async Task OnJobCompletedSendsTerminatedAndExitedEvents() // Read the configurationDone response await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + // Read the welcome message output event + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); await waitTask; // Complete the job — OnJobCompletedAsync pauses when stepping, @@ -849,6 +851,8 @@ public async Task WaitForCommandAsyncUnblocksOnCancellationDuringWait() Command = "configurationDone" }); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + // Read the welcome message output event await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); await waitTask; @@ -867,5 +871,178 @@ public async Task WaitForCommandAsyncUnblocksOnCancellationDuringWait() Assert.Equal(completedTask, finished); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WelcomeMessageSendsDefaultHelpWhenNull() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + // First message: configurationDone response + var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse); + + // Second message: welcome output event with default help text + var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"event\":\"output\"", welcomeMsg); + Assert.Contains("\"category\":\"console\"", welcomeMsg); + Assert.Contains("Actions Debug Console", welcomeMsg); + Assert.Contains("help", welcomeMsg); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WelcomeMessageShowsCustomMessageWhenProvided() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, + welcomeMessage: "Welcome to debugging!"); + await _debugger.StartAsync(jobContext.Object); + + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + // First: configurationDone response + var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse); + + // Second: custom welcome message + var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"event\":\"output\"", welcomeMsg); + Assert.Contains("Welcome to debugging!", welcomeMsg); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WelcomeMessageSendsNothingWhenEmpty() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, + welcomeMessage: ""); + await _debugger.StartAsync(jobContext.Object); + + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + // Read configurationDone response + var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse); + + // Send threads request — if welcome message was suppressed, this + // should be the next response (no output event in between) + await SendRequestAsync(stream, new Request + { + Seq = 2, + Type = "request", + Command = "threads" + }); + + var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"threads\"", threadsResponse); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WelcomeMessageSentOnlyOnce() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + + // First configurationDone + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse); + + // Welcome message should appear + var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"event\":\"output\"", welcomeMsg); + Assert.Contains("Actions Debug Console", welcomeMsg); + + // Second configurationDone — should NOT produce another welcome message + await SendRequestAsync(stream, new Request + { + Seq = 2, + Type = "request", + Command = "configurationDone" + }); + + var secondResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", secondResponse); + + // Next message should be threads response, not another welcome output + await SendRequestAsync(stream, new Request + { + Seq = 3, + Type = "request", + Command = "threads" + }); + + var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"threads\"", threadsResponse); + + await _debugger.StopAsync(); + } + } } } From a57b69ef3f2fc8e69e75cda36d99fe03049b3164 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 14 May 2026 13:21:23 +0100 Subject: [PATCH 4/7] Reset welcome message flag on client reconnect A reconnecting DAP client has lost its console output, so it should see the welcome message again. Reset _welcomeMessageSent in HandleClientConnected() so each new connection gets the message. The duplicate guard still prevents multiple sends within a single connection's handshake. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapDebugger.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 6823429d72c..ca0735bf2f8 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -514,6 +514,7 @@ internal async Task HandleMessageAsync(string messageJson, CancellationToken can internal void HandleClientConnected() { _isClientConnected = true; + _welcomeMessageSent = false; Trace.Info("Client connected to debug session"); // If we're paused, re-send the stopped event so the new client From d4d6c3e4a94c84042f9473b0cc12e4880b7344c0 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Mon, 18 May 2026 09:52:27 +0100 Subject: [PATCH 5/7] Use override flag variable for debugger welcome message Replace the null/empty/value tri-state semantics of DebuggerWelcomeMessage with an explicit OverrideDebuggerWelcomeMessage feature flag carried in the job variables. Run-service can now safely use omitempty on the welcome message string: when the flag is absent the runner shows the default help text, and when the flag is set the (possibly empty) message is used as-is. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Common/Constants.cs | 1 + src/Runner.Worker/Dap/DapDebugger.cs | 27 +++++++++---------- src/Runner.Worker/Dap/DebuggerConfig.cs | 14 +++++++--- src/Runner.Worker/ExecutionContext.cs | 3 ++- .../Pipelines/AgentJobRequestMessage.cs | 8 +++--- 5 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index 7a1f1cbb0a1..b07d60d39fc 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -179,6 +179,7 @@ public static class Features public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers"; public static readonly string BatchActionResolution = "actions_batch_action_resolution"; public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload"; + public static readonly string OverrideDebuggerWelcomeMessage = "actions_runner_override_debugger_welcome_message"; } // Node version migration related constants diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index ca0735bf2f8..6ea040ccfd8 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -833,24 +833,23 @@ internal void SendWelcomeMessage() } _welcomeMessageSent = true; - var welcomeMessage = _jobContext?.Global?.Debugger?.WelcomeMessage; - - // null → default help text - // "" → no message - // other → custom message - if (welcomeMessage == null) - { - SendOutput("console", DapReplParser.GetGeneralHelp()); - Trace.Info("Sent default welcome message"); - } - else if (welcomeMessage.Length > 0) + var debuggerConfig = _jobContext?.Global?.Debugger; + if (debuggerConfig?.OverrideWelcomeMessage == true) { - SendOutput("console", welcomeMessage); - Trace.Info("Sent custom welcome message"); + if (!string.IsNullOrEmpty(debuggerConfig.WelcomeMessage)) + { + SendOutput("console", debuggerConfig.WelcomeMessage); + Trace.Info("Sent custom welcome message"); + } + else + { + Trace.Info("Welcome message suppressed by override"); + } } else { - Trace.Info("Welcome message is empty, skipping"); + SendOutput("console", DapReplParser.GetGeneralHelp()); + Trace.Info("Sent default welcome message"); } } diff --git a/src/Runner.Worker/Dap/DebuggerConfig.cs b/src/Runner.Worker/Dap/DebuggerConfig.cs index 4d22091b93e..13106cb563d 100644 --- a/src/Runner.Worker/Dap/DebuggerConfig.cs +++ b/src/Runner.Worker/Dap/DebuggerConfig.cs @@ -8,10 +8,11 @@ namespace GitHub.Runner.Worker.Dap /// public sealed class DebuggerConfig { - public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel, string welcomeMessage = null) + public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel, bool overrideWelcomeMessage = false, string welcomeMessage = null) { Enabled = enabled; Tunnel = tunnel; + OverrideWelcomeMessage = overrideWelcomeMessage; WelcomeMessage = welcomeMessage; } @@ -25,8 +26,15 @@ public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel, string welcomeMes public DebuggerTunnelInfo Tunnel { get; } /// - /// Optional welcome message for the debugger console. - /// Null = show default help, empty = show nothing, non-empty = show as-is. + /// When true, the runner overrides the default welcome message with + /// . A null or empty + /// suppresses the message entirely. When false, the default help text is shown. + /// + public bool OverrideWelcomeMessage { get; } + + /// + /// Optional welcome message content for the debugger console. Only used when + /// is true. /// public string WelcomeMessage { get; } diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 4dcfd1354bf..d071790f37d 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -970,7 +970,8 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation Global.WriteDebug = Global.Variables.Step_Debug ?? false; // Debugger enabled flag (from acquire response). - Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel, message.DebuggerWelcomeMessage); + var overrideDebuggerWelcomeMessage = Global.Variables.GetBoolean(Constants.Runner.Features.OverrideDebuggerWelcomeMessage) ?? false; + Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel, overrideDebuggerWelcomeMessage, message.DebuggerWelcomeMessage); // Hook up JobServerQueueThrottling event, we will log warning on server tarpit. _jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived; diff --git a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs index 92d7de45b9e..c7fbd7e39f6 100644 --- a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs +++ b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs @@ -268,9 +268,11 @@ public DebuggerTunnelInfo DebuggerTunnel } /// - /// Optional welcome message to show in the debugger console when a - /// client connects. Null means "use the default help text", empty - /// string means "show nothing", any other value is displayed as-is. + /// Optional welcome message content shown in the debugger console when a client + /// connects. Only used when the + /// actions_runner_override_debugger_welcome_message feature flag is set + /// in ; a null or empty value with the flag set + /// suppresses the message entirely. /// [DataMember(EmitDefaultValue = false)] public string DebuggerWelcomeMessage From d4b902b533e3cc9c86dfc62c9e689c1ef5926c83 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Mon, 18 May 2026 09:52:35 +0100 Subject: [PATCH 6/7] Update debugger welcome message tests for override flag Adjust the DAP welcome message tests to exercise the new override flag behavior (default help when disabled, custom or suppressed when enabled), and collapse the now-redundant null/empty serialization round-trip tests into a single check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Sdk/RSWebApi/AgentJobRequestMessageL0.cs | 44 +-------------- src/Test/L0/Worker/DapDebuggerL0.cs | 56 +++++++++++++++++-- 2 files changed, 53 insertions(+), 47 deletions(-) diff --git a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs index 85c132e0140..667c6810e84 100644 --- a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs +++ b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs @@ -164,47 +164,7 @@ public void VerifyActionsDependenciesDeserialization_DefaultsToEmpty() [Fact] [Trait("Level", "L0")] [Trait("Category", "Common")] - public void VerifyDebuggerWelcomeMessageDeserialization_WhenAbsent() - { - // Arrange - var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); - string json = DoubleQuotify("{'EnableDebugger': true}"); - - // Act - using var stream = new MemoryStream(); - stream.Write(Encoding.UTF8.GetBytes(json)); - stream.Position = 0; - var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; - - // Assert — absent key should deserialize as null - Assert.NotNull(recoveredMessage); - Assert.Null(recoveredMessage.DebuggerWelcomeMessage); - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Common")] - public void VerifyDebuggerWelcomeMessageDeserialization_WhenEmpty() - { - // Arrange - var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); - string json = DoubleQuotify("{'DebuggerWelcomeMessage': ''}"); - - // Act - using var stream = new MemoryStream(); - stream.Write(Encoding.UTF8.GetBytes(json)); - stream.Position = 0; - var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; - - // Assert — empty string is preserved - Assert.NotNull(recoveredMessage); - Assert.Equal("", recoveredMessage.DebuggerWelcomeMessage); - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Common")] - public void VerifyDebuggerWelcomeMessageDeserialization_WithCustomMessage() + public void VerifyDebuggerWelcomeMessageRoundTrips() { // Arrange var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); @@ -216,7 +176,7 @@ public void VerifyDebuggerWelcomeMessageDeserialization_WithCustomMessage() stream.Position = 0; var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; - // Assert — custom message is preserved + // Assert Assert.NotNull(recoveredMessage); Assert.Equal("Welcome to debugging!", recoveredMessage.DebuggerWelcomeMessage); } diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index 2ebeb06b5fe..92efbaa00c9 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -236,7 +236,7 @@ private static async Task ReadWebSocketDataUntilAsync(WebSocket client, } } - private static Mock CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null, string welcomeMessage = null) + private static Mock CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null, bool overrideWelcomeMessage = false, string welcomeMessage = null) { var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo { @@ -245,7 +245,7 @@ private static Mock CreateJobContextWithTunnel(CancellationTo HostToken = "test-token", Port = port }; - var debuggerConfig = new DebuggerConfig(true, tunnel, welcomeMessage); + var debuggerConfig = new DebuggerConfig(true, tunnel, overrideWelcomeMessage, welcomeMessage); var jobContext = new Mock(); jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken); jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig }); @@ -875,7 +875,7 @@ public async Task WaitForCommandAsyncUnblocksOnCancellationDuringWait() [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task WelcomeMessageSendsDefaultHelpWhenNull() + public async Task WelcomeMessageSendsDefaultHelpWhenOverrideDisabled() { using (CreateTestContext()) { @@ -912,13 +912,14 @@ public async Task WelcomeMessageSendsDefaultHelpWhenNull() [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task WelcomeMessageShowsCustomMessageWhenProvided() + public async Task WelcomeMessageShowsCustomMessageWhenOverrideEnabled() { using (CreateTestContext()) { var port = GetFreePort(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var jobContext = CreateJobContextWithTunnel(cts.Token, port, + overrideWelcomeMessage: true, welcomeMessage: "Welcome to debugging!"); await _debugger.StartAsync(jobContext.Object); @@ -948,13 +949,14 @@ public async Task WelcomeMessageShowsCustomMessageWhenProvided() [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task WelcomeMessageSendsNothingWhenEmpty() + public async Task WelcomeMessageSuppressedWhenOverrideEnabledWithEmptyMessage() { using (CreateTestContext()) { var port = GetFreePort(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var jobContext = CreateJobContextWithTunnel(cts.Token, port, + overrideWelcomeMessage: true, welcomeMessage: ""); await _debugger.StartAsync(jobContext.Object); @@ -988,6 +990,50 @@ public async Task WelcomeMessageSendsNothingWhenEmpty() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WelcomeMessageSuppressedWhenOverrideEnabledWithNullMessage() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, + overrideWelcomeMessage: true, + welcomeMessage: null); + await _debugger.StartAsync(jobContext.Object); + + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + // Read configurationDone response + var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse); + + // Send threads request — if welcome message was suppressed, this + // should be the next response (no output event in between) + await SendRequestAsync(stream, new Request + { + Seq = 2, + Type = "request", + Command = "threads" + }); + + var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"threads\"", threadsResponse); + + await _debugger.StopAsync(); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] From 1116c2029bd91deeda5519948e40c3e615dee5df Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Mon, 18 May 2026 17:01:25 +0100 Subject: [PATCH 7/7] Clarify DebuggerWelcomeMessage doc comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DTPipelines/Pipelines/AgentJobRequestMessage.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs index c7fbd7e39f6..782878f79e4 100644 --- a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs +++ b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs @@ -268,11 +268,12 @@ public DebuggerTunnelInfo DebuggerTunnel } /// - /// Optional welcome message content shown in the debugger console when a client - /// connects. Only used when the - /// actions_runner_override_debugger_welcome_message feature flag is set - /// in ; a null or empty value with the flag set - /// suppresses the message entirely. + /// Optional welcome message shown in the debugger console when a client connects. + /// Only used when the actions_runner_override_debugger_welcome_message + /// feature flag is set to true in the job variables. With the flag set, + /// a non-empty value is shown as-is and a null or empty value suppresses the + /// default welcome message. When the flag is not set, the runner shows its + /// built-in help text and this field is ignored. /// [DataMember(EmitDefaultValue = false)] public string DebuggerWelcomeMessage