diff --git a/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs b/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs
new file mode 100644
index 000000000..3436d9169
--- /dev/null
+++ b/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs
@@ -0,0 +1,223 @@
+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);
+ // Force LF line breaks; YamlDotNet's Emitter calls WriteLine,
+ // which would otherwise produce CRLF on Windows and corrupt
+ // both the document-end stripping below and the per-line
+ // indentation pass that follows.
+ sw.NewLine = "\n";
+ var emitter = new Emitter(sw);
+ var adapter = new TemplateTokenYamlAdapter(emitter);
+ adapter.WriteStart();
+ WriteToken(adapter, token);
+ adapter.WriteEnd();
+
+ string raw = sw.ToString();
+ // Strip YAML document markers. The Emitter most commonly elides
+ // these for our use (DocumentStart isImplicit=true), but emits
+ // them for some scalar edge cases (e.g. empty strings) and may
+ // emit them on their own line for collection roots under some
+ // settings. Strip both shapes defensively so callers never see
+ // a leaked marker leak into the embedded fragment.
+ if (raw.StartsWith("--- ", StringComparison.Ordinal))
+ {
+ raw = raw.Substring(4);
+ }
+ else if (raw.StartsWith("---\n", 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();
+ }
+
+ ///
+ /// Mirrors 's recursive walk, with one
+ /// behavioural change: is emitted
+ /// via ToDisplayString() instead of ToString().
+ ///
+ ///
+ /// The workflow parser tokenizes a mixed scalar like
+ /// ${{ runner.os }}-primes as a single
+ /// whose internal expression is
+ /// format('{0}-primes', runner.os). ToString() emits
+ /// the normalized form verbatim; ToDisplayString() reverses
+ /// the format(...) rewrite so the user sees the original
+ /// authored form. Other token kinds delegate to the same writer
+ /// calls would make.
+ ///
+ private static void WriteToken(IObjectWriter writer, TemplateToken token)
+ {
+ switch (token?.Type ?? TokenType.Null)
+ {
+ case TokenType.Null:
+ writer.WriteNull();
+ break;
+ case TokenType.Boolean:
+ writer.WriteBoolean(((BooleanToken)token).Value);
+ break;
+ case TokenType.Number:
+ writer.WriteNumber(((NumberToken)token).Value);
+ break;
+ case TokenType.String:
+ writer.WriteString(token.ToString());
+ break;
+ case TokenType.BasicExpression:
+ writer.WriteString(((BasicExpressionToken)token).ToDisplayString());
+ break;
+ case TokenType.InsertExpression:
+ writer.WriteString(token.ToString());
+ break;
+ case TokenType.Mapping:
+ writer.WriteMappingStart();
+ foreach (var pair in (MappingToken)token)
+ {
+ WriteToken(writer, pair.Key);
+ WriteToken(writer, pair.Value);
+ }
+ writer.WriteMappingEnd();
+ break;
+ case TokenType.Sequence:
+ writer.WriteSequenceStart();
+ foreach (var item in (SequenceToken)token)
+ {
+ WriteToken(writer, item);
+ }
+ writer.WriteSequenceEnd();
+ break;
+ default:
+ throw new NotSupportedException($"Unexpected token type '{token.GetType()}'.");
+ }
+ }
+ }
+}
diff --git a/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs b/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs
new file mode 100644
index 000000000..8406bae66
--- /dev/null
+++ b/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs
@@ -0,0 +1,191 @@
+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()
+ {
+ // A StringToken constructed directly with the literal text
+ // round-trips unchanged. (The workflow parser does NOT produce
+ // a StringToken for this input — see
+ // Serialize_ReversesFormatRewriteForCompositeExpression — but
+ // direct StringToken construction must still preserve the
+ // literal verbatim.)
+ 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_ReversesFormatRewriteForCompositeExpression()
+ {
+ // The workflow parser tokenizes a mixed scalar like
+ // `${{ runner.os }}-primes` as a single BasicExpressionToken
+ // whose internal expression is `format('{0}-primes', runner.os)`.
+ // The adapter must surface the author-facing form, not the
+ // parser's normalized rewrite.
+ var token = Expr("format('{0}-primes', runner.os)");
+ string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
+ Assert.Contains("${{ runner.os }}-primes", yaml);
+ Assert.DoesNotContain("format(", 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"));
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Serialize_AlwaysUsesLfLineBreaks()
+ {
+ // Regression: YamlDotNet's Emitter calls WriteLine, which on
+ // Windows produces CRLF (the host's Environment.NewLine).
+ // Serialize must force LF so the rendered view round-trips
+ // regardless of platform.
+ var map = new MappingToken(null, null, null);
+ map.Add(Str("k1"), Str("v1"));
+ map.Add(Str("k2"), Num(2));
+ map.Add(Str("k3"), Bool(true));
+ string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 2);
+ Assert.DoesNotContain("\r", yaml);
+ }
+ }
+}