diff --git a/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs b/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs
new file mode 100644
index 000000000..37f408e9d
--- /dev/null
+++ b/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs
@@ -0,0 +1,148 @@
+using System;
+using System.Globalization;
+using System.IO;
+using GitHub.DistributedTask.ObjectTemplating;
+using GitHub.DistributedTask.ObjectTemplating.Tokens;
+using GitHub.Runner.Sdk;
+using YamlDotNet.Core;
+using YamlDotNet.Core.Events;
+
+namespace GitHub.Runner.Worker.Dap
+{
+ ///
+ /// Adapts a YamlDotNet as a DT
+ /// so a DOM
+ /// can be serialized back to YAML preserving its pre-evaluation form
+ /// (basic ${{ }} expressions are written through verbatim).
+ ///
+ /// Used by the DAP execution view to surface user-authored step
+ /// parameters (env:, with:, run:, ...) without
+ /// any expression substitution.
+ ///
+ internal sealed class TemplateTokenYamlAdapter : IObjectWriter
+ {
+ private readonly IEmitter _emitter;
+
+ public TemplateTokenYamlAdapter(IEmitter emitter)
+ {
+ ArgUtil.NotNull(emitter, nameof(emitter));
+ _emitter = emitter;
+ }
+
+ public void WriteStart()
+ {
+ _emitter.Emit(new StreamStart());
+ _emitter.Emit(new DocumentStart(null, null, true));
+ }
+
+ public void WriteEnd()
+ {
+ _emitter.Emit(new DocumentEnd(true));
+ _emitter.Emit(new StreamEnd());
+ }
+
+ public void WriteNull() =>
+ _emitter.Emit(new Scalar(null, null, "null", ScalarStyle.Plain, true, false));
+
+ public void WriteBoolean(bool value) =>
+ _emitter.Emit(new Scalar(null, null, value ? "true" : "false", ScalarStyle.Plain, true, false));
+
+ public void WriteNumber(double value) =>
+ _emitter.Emit(new Scalar(null, null, value.ToString("R", CultureInfo.InvariantCulture), ScalarStyle.Plain, true, false));
+
+ public void WriteString(string value)
+ {
+ if (value == null)
+ {
+ WriteNull();
+ return;
+ }
+ // Multi-line strings render as block literal so embedded
+ // newlines survive the YAML round trip.
+ var style = value.IndexOf('\n') >= 0 ? ScalarStyle.Literal : ScalarStyle.Any;
+ _emitter.Emit(new Scalar(null, null, value, style, true, true));
+ }
+
+ public void WriteSequenceStart() =>
+ _emitter.Emit(new SequenceStart(null, null, true, SequenceStyle.Any));
+
+ public void WriteSequenceEnd() =>
+ _emitter.Emit(new SequenceEnd());
+
+ public void WriteMappingStart() =>
+ _emitter.Emit(new MappingStart(null, null, true, MappingStyle.Any));
+
+ public void WriteMappingEnd() =>
+ _emitter.Emit(new MappingEnd());
+
+ ///
+ /// Serialize a TemplateToken to a YAML fragment ready to embed
+ /// under a parent key. Each non-empty line is prefixed by
+ /// spaces. Trailing newlines and
+ /// the YAML stream start/document markers are stripped, so the
+ /// caller controls line breaks.
+ ///
+ ///
+ /// Empty mappings render as {} and empty sequences as
+ /// [] via YamlDotNet's flow style fallback for empty
+ /// collections.
+ ///
+ internal static string Serialize(TemplateToken token, int indentSpaces)
+ {
+ if (indentSpaces < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(indentSpaces));
+ }
+
+ using var sw = new StringWriter(CultureInfo.InvariantCulture);
+ var emitter = new Emitter(sw);
+ var adapter = new TemplateTokenYamlAdapter(emitter);
+ TemplateWriter.Write(adapter, token);
+
+ string raw = sw.ToString();
+ // Strip YAML document markers ("--- " prefix and "\n..." suffix).
+ if (raw.StartsWith("--- ", StringComparison.Ordinal))
+ {
+ raw = raw.Substring(4);
+ }
+ const string DocEndMarker = "\n...";
+ if (raw.EndsWith(DocEndMarker + "\n", StringComparison.Ordinal))
+ {
+ raw = raw.Substring(0, raw.Length - DocEndMarker.Length - 1);
+ }
+ else if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal))
+ {
+ raw = raw.Substring(0, raw.Length - DocEndMarker.Length);
+ }
+ raw = raw.TrimEnd('\n');
+
+ if (indentSpaces == 0)
+ {
+ return raw;
+ }
+
+ // Re-indent every non-empty line. Empty lines remain empty
+ // so YAML block-literal blank lines stay valid.
+ var pad = new string(' ', indentSpaces);
+ var sb = new System.Text.StringBuilder(raw.Length + indentSpaces * 4);
+ int i = 0;
+ while (i < raw.Length)
+ {
+ int end = raw.IndexOf('\n', i);
+ int lineEnd = end < 0 ? raw.Length : end;
+ if (lineEnd > i)
+ {
+ sb.Append(pad);
+ sb.Append(raw, i, lineEnd - i);
+ }
+ if (end < 0)
+ {
+ break;
+ }
+ sb.Append('\n');
+ i = end + 1;
+ }
+ return sb.ToString();
+ }
+ }
+}
diff --git a/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs b/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs
new file mode 100644
index 000000000..b3e825323
--- /dev/null
+++ b/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs
@@ -0,0 +1,155 @@
+using GitHub.DistributedTask.ObjectTemplating.Tokens;
+using GitHub.Runner.Worker.Dap;
+using Xunit;
+
+namespace GitHub.Runner.Common.Tests.Worker
+{
+ public sealed class TemplateTokenYamlAdapterL0
+ {
+ private static StringToken Str(string s) => new(null, null, null, s);
+ private static BooleanToken Bool(bool b) => new(null, null, null, b);
+ private static NumberToken Num(double n) => new(null, null, null, n);
+ private static BasicExpressionToken Expr(string s) => new(null, null, null, s);
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Serialize_StringScalar()
+ {
+ Assert.Equal("hello", TemplateTokenYamlAdapter.Serialize(Str("hello"), 0));
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Serialize_BooleanScalar()
+ {
+ Assert.Equal("true", TemplateTokenYamlAdapter.Serialize(Bool(true), 0));
+ Assert.Equal("false", TemplateTokenYamlAdapter.Serialize(Bool(false), 0));
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Serialize_NumberScalar()
+ {
+ Assert.Equal("10", TemplateTokenYamlAdapter.Serialize(Num(10), 0));
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Serialize_NullToken_RendersAsNull()
+ {
+ Assert.Equal("null", TemplateTokenYamlAdapter.Serialize(null, 0));
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Serialize_PreservesBasicExpression()
+ {
+ var token = Expr("runner.os");
+ string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
+ Assert.Contains("${{ runner.os }}", yaml);
+ Assert.DoesNotContain("Linux", yaml);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Serialize_PreservesCompositeExpressionInStringToken()
+ {
+ // Composite strings like `${{ runner.os }}-primes` are parsed
+ // as a StringToken whose value is exactly that literal. The
+ // adapter must round-trip the literal unchanged.
+ var token = Str("${{ runner.os }}-primes");
+ string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
+ Assert.Contains("${{ runner.os }}-primes", yaml);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Serialize_NestedMapping()
+ {
+ var inner = new MappingToken(null, null, null);
+ inner.Add(Str("b"), Num(1));
+ inner.Add(Str("c"), Expr("x"));
+ var outer = new MappingToken(null, null, null);
+ outer.Add(Str("a"), inner);
+
+ string yaml = TemplateTokenYamlAdapter.Serialize(outer, 0);
+
+ Assert.Contains("a:", yaml);
+ Assert.Contains("b: 1", yaml);
+ Assert.Contains("c: ${{ x }}", yaml);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Serialize_EmptyMapping()
+ {
+ var token = new MappingToken(null, null, null);
+ string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
+ Assert.Equal("{}", yaml);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Serialize_EmptySequence()
+ {
+ var token = new SequenceToken(null, null, null);
+ string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
+ Assert.Equal("[]", yaml);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Serialize_MultilineString_UsesBlockScalar()
+ {
+ var token = Str("line1\nline2\nline3");
+ string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
+ // Block-literal indicator `|` appears for multi-line scalars.
+ Assert.Contains("|", yaml);
+ Assert.Contains("line1", yaml);
+ Assert.Contains("line2", yaml);
+ Assert.Contains("line3", yaml);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Serialize_IndentLevel_PrefixesNonEmptyLines()
+ {
+ var map = new MappingToken(null, null, null);
+ map.Add(Str("k1"), Str("v1"));
+ map.Add(Str("k2"), Str("v2"));
+
+ string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 4);
+
+ foreach (var line in yaml.Split('\n'))
+ {
+ if (line.Length > 0)
+ {
+ Assert.StartsWith(" ", line);
+ }
+ }
+ Assert.Contains("k1: v1", yaml);
+ Assert.Contains("k2: v2", yaml);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Serialize_NoTrailingNewline()
+ {
+ var token = Str("hello");
+ string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
+ Assert.False(yaml.EndsWith("\n"));
+ }
+ }
+}