From 5e200d4cb9e01aedb2774f7eae14a9da4403dea8 Mon Sep 17 00:00:00 2001 From: ricky Date: Sat, 14 Feb 2026 22:18:02 +0800 Subject: [PATCH 1/4] Add OutputTruncationHelper for managing large command outputs - Introduced OutputTruncationHelper class to truncate large outputs, saving full content to a temporary file for retrieval. - Updated PowerShellTools to utilize OutputTruncationHelper for response handling, ensuring outputs are truncated when exceeding the defined threshold. - Added unit tests for OutputTruncationHelper to validate functionality, including edge cases for output size and file saving. --- .../Helpers/OutputTruncationHelper.cs | 158 +++++++++++ .../PowerShell.MCP.Proxy.csproj | 5 + PowerShell.MCP.Proxy/Tools/PowerShellTools.cs | 24 +- .../Unit/Proxy/OutputTruncationHelperTests.cs | 266 ++++++++++++++++++ 4 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 PowerShell.MCP.Proxy/Helpers/OutputTruncationHelper.cs create mode 100644 Tests/Unit/Proxy/OutputTruncationHelperTests.cs diff --git a/PowerShell.MCP.Proxy/Helpers/OutputTruncationHelper.cs b/PowerShell.MCP.Proxy/Helpers/OutputTruncationHelper.cs new file mode 100644 index 0000000..40bba9d --- /dev/null +++ b/PowerShell.MCP.Proxy/Helpers/OutputTruncationHelper.cs @@ -0,0 +1,158 @@ +namespace PowerShell.MCP.Proxy.Helpers; + +/// +/// Truncates large command output to preserve AI context window budget. +/// Saves the full output to a temp file so the AI can retrieve it via Read tool if needed. +/// +public static class OutputTruncationHelper +{ + internal const int TruncationThreshold = 5_000; + internal const int PreviewHeadSize = 1000; + internal const int PreviewTailSize = 1000; + internal const string OutputDirectoryName = "PowerShell.MCP.Output"; + internal const int MaxFileAgeMinutes = 120; + internal const int NewlineScanLimit = 200; + + /// + /// Returns the output unchanged if within threshold, otherwise saves the full content + /// to a temp file and returns a head+tail preview with the file path. + /// + public static string TruncateIfNeeded(string output, string? outputDirectory = null) + { + if (output.Length <= TruncationThreshold) + return output; + + // Compute newline-aligned head boundary + var headEnd = FindHeadBoundary(output, PreviewHeadSize); + var head = output[..headEnd]; + + // Compute newline-aligned tail boundary + var tailStart = FindTailBoundary(output, PreviewTailSize); + var tail = output[tailStart..]; + + var omitted = output.Length - head.Length - tail.Length; + + var filePath = SaveOutputToFile(output, outputDirectory); + + var sb = new System.Text.StringBuilder(); + + if (filePath != null) + { + sb.AppendLine($"Output too large ({output.Length} characters). Full output saved to: {filePath}"); + sb.AppendLine($"Use invoke_expression('Get-Content \"{filePath}\"') or Read tool to access the full output."); + } + else + { + // Disk save failed — still provide the preview without a file path + sb.AppendLine($"Output too large ({output.Length} characters). Could not save full output to file."); + } + + sb.AppendLine(); + sb.AppendLine("--- Preview (first ~1000 chars) ---"); + sb.AppendLine(head); + sb.AppendLine($"--- truncated ({omitted} chars omitted) ---"); + sb.AppendLine("--- Preview (last ~1000 chars) ---"); + sb.Append(tail); + + return sb.ToString(); + } + + /// + /// Saves the full output to a timestamped temp file and triggers opportunistic cleanup. + /// Returns the file path on success, null on failure. + /// + internal static string? SaveOutputToFile(string output, string? outputDirectory = null) + { + try + { + var directory = outputDirectory + ?? Path.Combine(Path.GetTempPath(), OutputDirectoryName); + + Directory.CreateDirectory(directory); + + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff"); + var random = Path.GetRandomFileName(); + var fileName = $"pwsh_output_{timestamp}_{random}.txt"; + var filePath = Path.Combine(directory, fileName); + + File.WriteAllText(filePath, output); + + // Opportunistic cleanup — never let it block or fail the save + CleanupOldOutputFiles(directory); + + return filePath; + } + catch + { + return null; + } + } + + /// + /// Deletes pwsh_output_*.txt files older than minutes. + /// Each deletion is individually guarded so a locked file does not prevent other cleanups. + /// + internal static void CleanupOldOutputFiles(string? directory = null) + { + try + { + var dir = directory + ?? Path.Combine(Path.GetTempPath(), OutputDirectoryName); + + if (!Directory.Exists(dir)) + return; + + var cutoff = DateTime.Now.AddMinutes(-MaxFileAgeMinutes); + + foreach (var file in Directory.EnumerateFiles(dir, "pwsh_output_*.txt")) + { + try + { + if (File.GetLastWriteTime(file) < cutoff) + File.Delete(file); + } + catch (IOException) + { + // Another thread may be writing — safe to ignore + } + } + } + catch + { + // Directory enumeration itself failed — nothing to clean up + } + } + + /// + /// Finds a head cut position aligned to the nearest preceding newline within scan limit. + /// + private static int FindHeadBoundary(string output, int nominalSize) + { + if (nominalSize >= output.Length) + return output.Length; + + // Search backward from nominalSize for a newline, up to NewlineScanLimit chars + var searchStart = Math.Max(0, nominalSize - NewlineScanLimit); + var lastNewline = output.LastIndexOf('\n', nominalSize - 1, nominalSize - searchStart); + + // Cut after the newline to keep complete lines in the head + return lastNewline >= 0 ? lastNewline + 1 : nominalSize; + } + + /// + /// Finds a tail start position aligned to the nearest following newline within scan limit. + /// + private static int FindTailBoundary(string output, int nominalSize) + { + var nominalStart = output.Length - nominalSize; + if (nominalStart <= 0) + return 0; + + // Search forward from nominalStart for a newline, up to NewlineScanLimit chars + var searchEnd = Math.Min(output.Length, nominalStart + NewlineScanLimit); + var nextNewline = output.IndexOf('\n', nominalStart, searchEnd - nominalStart); + + // Start at the character after the newline to begin on a fresh line + return nextNewline >= 0 ? nextNewline + 1 : nominalStart; + } +} diff --git a/PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj b/PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj index 2484b38..21b83f3 100644 --- a/PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj +++ b/PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj @@ -24,6 +24,11 @@ false + + + + + diff --git a/PowerShell.MCP.Proxy/Tools/PowerShellTools.cs b/PowerShell.MCP.Proxy/Tools/PowerShellTools.cs index 7b8ad4d..205c0a3 100644 --- a/PowerShell.MCP.Proxy/Tools/PowerShellTools.cs +++ b/PowerShell.MCP.Proxy/Tools/PowerShellTools.cs @@ -103,7 +103,7 @@ public static async Task GetCurrentLocation( response.Append(completedOutputs); } response.Append(result); - return response.ToString(); + return OutputTruncationHelper.TruncateIfNeeded(response.ToString()); } catch (Exception ex) { @@ -213,7 +213,7 @@ public static async Task InvokeExpression( response.AppendLine(); response.Append(completedOutputs); } - return response.ToString(); + return OutputTruncationHelper.TruncateIfNeeded(response.ToString()); } // Console switched - get location (DLL will automatically include its own cached outputs) @@ -247,7 +247,7 @@ public static async Task InvokeExpression( response.AppendLine(); response.Append(completedOutputs); } - return response.ToString(); + return OutputTruncationHelper.TruncateIfNeeded(response.ToString()); } // Check for local variable assignments without scope prefix @@ -323,7 +323,7 @@ public static async Task InvokeExpression( busyResponse.AppendLine(); busyResponse.Append(completedOutputs); } - return busyResponse.ToString(); + return OutputTruncationHelper.TruncateIfNeeded(busyResponse.ToString()); } break; @@ -375,7 +375,7 @@ public static async Task InvokeExpression( } timeoutResponse.AppendLine(); timeoutResponse.Append("Use wait_for_completion tool to wait and retrieve the result."); - return timeoutResponse.ToString(); + return OutputTruncationHelper.TruncateIfNeeded(timeoutResponse.ToString()); case PipeStatus.Completed: // Result was cached - return status @@ -403,10 +403,10 @@ public static async Task InvokeExpression( cachedResponse.AppendLine(cachedStatusLine); cachedResponse.AppendLine(); cachedResponse.Append("Result cached. Will be returned on next tool call."); - return cachedResponse.ToString(); + return OutputTruncationHelper.TruncateIfNeeded(cachedResponse.ToString()); case "error": - return jsonResponse.Message ?? $"Error from PowerShell.MCP module: {jsonResponse.Error}"; + return OutputTruncationHelper.TruncateIfNeeded(jsonResponse.Message ?? $"Error from PowerShell.MCP module: {jsonResponse.Error}"); case "success": // Normal completion - use body as result @@ -454,7 +454,7 @@ public static async Task InvokeExpression( successResponse.AppendLine(); successResponse.Append(output); } - return successResponse.ToString(); + return OutputTruncationHelper.TruncateIfNeeded(successResponse.ToString()); } } } @@ -465,7 +465,7 @@ public static async Task InvokeExpression( } // Fallback: return result as-is (shouldn't happen with new DLL) - return result; + return OutputTruncationHelper.TruncateIfNeeded(result); } catch (Exception ex) { @@ -653,7 +653,7 @@ private static string BuildWaitResponse(List closedConsoleMessages, stri return "No busy consoles or cached results."; } - return response.ToString(); + return OutputTruncationHelper.TruncateIfNeeded(response.ToString()); } [McpServerTool] @@ -710,7 +710,7 @@ await powerShellService.ExecuteSilentAsync( reuseResponse.AppendLine("ℹ️ Did not launch a new console. An existing standby console is available and will be reused. To force a new console, provide the reason parameter."); reuseResponse.AppendLine(); reuseResponse.Append(reuseLocationResult); - return reuseResponse.ToString(); + return OutputTruncationHelper.TruncateIfNeeded(reuseResponse.ToString()); } // No standby console found, fall through to create a new one } @@ -752,7 +752,7 @@ await powerShellService.ExecuteSilentAsync( response.AppendLine("PowerShell console started successfully with PowerShell.MCP module imported."); response.AppendLine(); response.Append(startResult); - return response.ToString(); + return OutputTruncationHelper.TruncateIfNeeded(response.ToString()); } /// diff --git a/Tests/Unit/Proxy/OutputTruncationHelperTests.cs b/Tests/Unit/Proxy/OutputTruncationHelperTests.cs new file mode 100644 index 0000000..e33af46 --- /dev/null +++ b/Tests/Unit/Proxy/OutputTruncationHelperTests.cs @@ -0,0 +1,266 @@ +using PowerShell.MCP.Proxy.Helpers; +using Xunit; + +namespace PowerShell.MCP.Tests.Unit.Proxy; + +public class OutputTruncationHelperTests : IDisposable +{ + private readonly string _testDir; + + public OutputTruncationHelperTests() + { + _testDir = Path.Combine(Path.GetTempPath(), $"OutputTruncationHelperTests_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDir); + } + + public void Dispose() + { + try { Directory.Delete(_testDir, recursive: true); } + catch { /* best-effort cleanup */ } + } + + #region TruncateIfNeeded Tests + + [Fact] + public void TruncateIfNeeded_Empty_ReturnsEmpty() + { + var result = OutputTruncationHelper.TruncateIfNeeded("", _testDir); + Assert.Equal("", result); + } + + [Fact] + public void TruncateIfNeeded_UnderThreshold_ReturnsUnchanged() + { + var output = new string('A', OutputTruncationHelper.TruncationThreshold - 1); + var result = OutputTruncationHelper.TruncateIfNeeded(output, _testDir); + Assert.Equal(output, result); + } + + [Fact] + public void TruncateIfNeeded_ExactThreshold_ReturnsUnchanged() + { + var output = new string('B', OutputTruncationHelper.TruncationThreshold); + var result = OutputTruncationHelper.TruncateIfNeeded(output, _testDir); + Assert.Equal(output, result); + } + + [Fact] + public void TruncateIfNeeded_OverThreshold_ReturnsTruncatedWithFilePath() + { + var output = new string('C', OutputTruncationHelper.TruncationThreshold + 1); + var result = OutputTruncationHelper.TruncateIfNeeded(output, _testDir); + + // Should contain file path info + Assert.Contains("Full output saved to:", result); + Assert.Contains("pwsh_output_", result); + Assert.Contains(".txt", result); + } + + [Fact] + public void TruncateIfNeeded_OverThreshold_SavesFullContentToFile() + { + var output = new string('D', OutputTruncationHelper.TruncationThreshold + 500); + OutputTruncationHelper.TruncateIfNeeded(output, _testDir); + + // Verify file was saved with full content + var files = Directory.GetFiles(_testDir, "pwsh_output_*.txt"); + Assert.Single(files); + var savedContent = File.ReadAllText(files[0]); + Assert.Equal(output, savedContent); + } + + [Fact] + public void TruncateIfNeeded_OverThreshold_PreviewContainsHeadAndTail() + { + // Build output with identifiable head and tail regions + var head = "HEAD_MARKER_" + new string('X', 500); + var middle = new string('M', OutputTruncationHelper.TruncationThreshold); + var tail = new string('Y', 500) + "_TAIL_MARKER"; + var output = head + middle + tail; + + var result = OutputTruncationHelper.TruncateIfNeeded(output, _testDir); + + Assert.Contains("HEAD_MARKER_", result); + Assert.Contains("_TAIL_MARKER", result); + Assert.Contains("truncated", result); + } + + [Fact] + public void TruncateIfNeeded_OverThreshold_PreviewTailMatchesOutputEnd() + { + // The tail preview should end with the same content as the original output + var tailContent = "FINAL_LINE_OF_OUTPUT"; + var output = new string('Z', OutputTruncationHelper.TruncationThreshold + 2000) + tailContent; + + var result = OutputTruncationHelper.TruncateIfNeeded(output, _testDir); + + Assert.EndsWith(tailContent, result); + } + + [Fact] + public void TruncateIfNeeded_OverThreshold_HeadAlignsToNewline() + { + // Place a newline within the scan range before PreviewHeadSize + // Head should cut at the newline boundary rather than at exactly PreviewHeadSize + var headSize = OutputTruncationHelper.PreviewHeadSize; + var newlinePos = headSize - 50; // newline within scan range + + var chars = new char[OutputTruncationHelper.TruncationThreshold + 2000]; + Array.Fill(chars, 'A'); + chars[newlinePos] = '\n'; + var output = new string(chars); + + var result = OutputTruncationHelper.TruncateIfNeeded(output, _testDir); + + // The head preview should end at or near the newline, not at PreviewHeadSize + // Extract the head preview section (after the metadata, before "truncated") + var truncatedIndex = result.IndexOf("truncated"); + Assert.True(truncatedIndex > 0, "Should contain 'truncated' marker"); + + // The head preview area should not contain content beyond the newline + // (it should have been cut at the \n boundary) + var previewSection = result.Substring(0, truncatedIndex); + // Head portion should be shorter than PreviewHeadSize since it aligned to newline + Assert.Contains("\n", previewSection); + } + + [Fact] + public void TruncateIfNeeded_OverThreshold_TailAlignsToNewline() + { + // Place a newline near the tail start position + var tailSize = OutputTruncationHelper.PreviewTailSize; + var outputLength = OutputTruncationHelper.TruncationThreshold + 2000; + var tailStartPos = outputLength - tailSize; + var newlinePos = tailStartPos + 50; // newline shortly after tail start, within scan range + + var chars = new char[outputLength]; + Array.Fill(chars, 'B'); + chars[newlinePos] = '\n'; + var output = new string(chars); + + var result = OutputTruncationHelper.TruncateIfNeeded(output, _testDir); + + // The tail preview should start at the newline boundary + var lastPreviewMarker = result.LastIndexOf("Preview (last"); + Assert.True(lastPreviewMarker > 0, "Should contain tail preview marker"); + + // The tail section after the marker should start near the newline + var tailSection = result.Substring(lastPreviewMarker); + Assert.Contains("\n", tailSection); + } + + [Fact] + public void TruncateIfNeeded_OverThreshold_NoNewlineInScanRange_HardCuts() + { + // Output with no newlines at all — should hard-cut at exact positions + var output = new string('Q', OutputTruncationHelper.TruncationThreshold + 3000); + + var result = OutputTruncationHelper.TruncateIfNeeded(output, _testDir); + + // Should still produce a valid truncated result with head and tail + Assert.Contains("truncated", result); + Assert.Contains("Preview", result); + // The total result should be much shorter than the original output + Assert.True(result.Length < output.Length, "Truncated result should be shorter than original"); + } + + [Fact] + public void TruncateIfNeeded_FileSaveFails_StillReturnsPreview() + { + // Use an invalid/non-writable directory path to force save failure + var invalidDir = Path.Combine(_testDir, new string('X', 300), "nonexistent", "deeply", "nested"); + + var output = new string('E', OutputTruncationHelper.TruncationThreshold + 1000); + var result = OutputTruncationHelper.TruncateIfNeeded(output, invalidDir); + + // Should still return a preview even though file save failed + Assert.Contains("truncated", result); + Assert.Contains("Preview", result); + // Should NOT contain "saved to" since the save failed + Assert.DoesNotContain("Full output saved to:", result); + } + + [Fact] + public void TruncateIfNeeded_CustomOutputDirectory_SavesToSpecifiedDir() + { + var customDir = Path.Combine(_testDir, "custom_output"); + Directory.CreateDirectory(customDir); + + var output = new string('F', OutputTruncationHelper.TruncationThreshold + 500); + var result = OutputTruncationHelper.TruncateIfNeeded(output, customDir); + + // File should be saved in the custom directory + var files = Directory.GetFiles(customDir, "pwsh_output_*.txt"); + Assert.Single(files); + Assert.Contains(customDir.Replace("\\", "/"), result.Replace("\\", "/")); + } + + [Fact] + public void TruncateIfNeeded_MessageShowsCharacters_NotKB() + { + var output = new string('G', OutputTruncationHelper.TruncationThreshold + 1000); + var result = OutputTruncationHelper.TruncateIfNeeded(output, _testDir); + + Assert.Contains("characters", result); + Assert.DoesNotContain("KB", result); + Assert.DoesNotContain("bytes", result); + } + + #endregion + + #region CleanupOldOutputFiles Tests + + [Fact] + public void CleanupOldOutputFiles_RemovesOldFiles() + { + // Create a file and set its last write time to beyond MaxFileAgeMinutes + var oldFile = Path.Combine(_testDir, "pwsh_output_old_test.txt"); + File.WriteAllText(oldFile, "old content"); + File.SetLastWriteTime(oldFile, DateTime.Now.AddMinutes(-(OutputTruncationHelper.MaxFileAgeMinutes + 10))); + + OutputTruncationHelper.CleanupOldOutputFiles(_testDir); + + Assert.False(File.Exists(oldFile), "Old file should have been deleted"); + } + + [Fact] + public void CleanupOldOutputFiles_KeepsRecentFiles() + { + // Create a recent file (within MaxFileAgeMinutes) + var recentFile = Path.Combine(_testDir, "pwsh_output_recent_test.txt"); + File.WriteAllText(recentFile, "recent content"); + // Last write time is now (default), well within the threshold + + OutputTruncationHelper.CleanupOldOutputFiles(_testDir); + + Assert.True(File.Exists(recentFile), "Recent file should NOT have been deleted"); + } + + [Fact] + public void CleanupOldOutputFiles_NonexistentDir_NoThrow() + { + var nonexistentDir = Path.Combine(_testDir, "does_not_exist_" + Guid.NewGuid().ToString("N")); + + // Should not throw even when directory doesn't exist + var exception = Record.Exception(() => OutputTruncationHelper.CleanupOldOutputFiles(nonexistentDir)); + Assert.Null(exception); + } + + [Fact] + public void CleanupOldOutputFiles_IgnoresIOException() + { + // Create an old file + var lockedFile = Path.Combine(_testDir, "pwsh_output_locked_test.txt"); + File.WriteAllText(lockedFile, "locked content"); + File.SetLastWriteTime(lockedFile, DateTime.Now.AddMinutes(-(OutputTruncationHelper.MaxFileAgeMinutes + 10))); + + // Lock the file by opening it with exclusive access + using var stream = new FileStream(lockedFile, FileMode.Open, FileAccess.Read, FileShare.None); + + // Should not throw even though the file is locked and cannot be deleted + var exception = Record.Exception(() => OutputTruncationHelper.CleanupOldOutputFiles(_testDir)); + Assert.Null(exception); + } + + #endregion +} From 9780994b9555f461fbbfd2b433436c102172401a Mon Sep 17 00:00:00 2001 From: ricky Date: Sat, 14 Feb 2026 22:48:35 +0800 Subject: [PATCH 2/4] Enhance OutputTruncationHelper with validation for truncation threshold --- .../Helpers/OutputTruncationHelper.cs | 15 +++++++++++++++ Tests/Unit/Proxy/OutputTruncationHelperTests.cs | 4 ++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/PowerShell.MCP.Proxy/Helpers/OutputTruncationHelper.cs b/PowerShell.MCP.Proxy/Helpers/OutputTruncationHelper.cs index 40bba9d..c78b456 100644 --- a/PowerShell.MCP.Proxy/Helpers/OutputTruncationHelper.cs +++ b/PowerShell.MCP.Proxy/Helpers/OutputTruncationHelper.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace PowerShell.MCP.Proxy.Helpers; /// @@ -9,6 +11,19 @@ public static class OutputTruncationHelper internal const int TruncationThreshold = 5_000; internal const int PreviewHeadSize = 1000; internal const int PreviewTailSize = 1000; + + // The threshold must exceed the combined preview sizes; otherwise the head + // and tail slices overlap, producing duplicated content in the preview. + private static readonly bool _ = Validate(); + private static bool Validate() + { + Debug.Assert( + TruncationThreshold > PreviewHeadSize + PreviewTailSize, + $"TruncationThreshold ({TruncationThreshold}) must be greater than " + + $"PreviewHeadSize + PreviewTailSize ({PreviewHeadSize + PreviewTailSize}) " + + "to avoid overlapping head/tail previews."); + return true; + } internal const string OutputDirectoryName = "PowerShell.MCP.Output"; internal const int MaxFileAgeMinutes = 120; internal const int NewlineScanLimit = 200; diff --git a/Tests/Unit/Proxy/OutputTruncationHelperTests.cs b/Tests/Unit/Proxy/OutputTruncationHelperTests.cs index e33af46..96124bd 100644 --- a/Tests/Unit/Proxy/OutputTruncationHelperTests.cs +++ b/Tests/Unit/Proxy/OutputTruncationHelperTests.cs @@ -167,8 +167,8 @@ public void TruncateIfNeeded_OverThreshold_NoNewlineInScanRange_HardCuts() [Fact] public void TruncateIfNeeded_FileSaveFails_StillReturnsPreview() { - // Use an invalid/non-writable directory path to force save failure - var invalidDir = Path.Combine(_testDir, new string('X', 300), "nonexistent", "deeply", "nested"); + // Null characters are universally invalid in file paths across all platforms + var invalidDir = Path.Combine(_testDir, "invalid\0dir"); var output = new string('E', OutputTruncationHelper.TruncationThreshold + 1000); var result = OutputTruncationHelper.TruncateIfNeeded(output, invalidDir); From a175fe2ce9c6cb8bbbcee254ad60c3a250def5fa Mon Sep 17 00:00:00 2001 From: ricky Date: Sun, 15 Feb 2026 14:50:49 +0800 Subject: [PATCH 3/4] Move OutputTruncationHelper to shared library and apply truncation in DLL module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Truncation now happens in PowerShellCommunication.NotifyResultReady() before caching, so only the truncated preview crosses the named pipe — reducing pipe transfer size and memory overhead. Full output is saved to a temp file on the DLL side. - Add PowerShell.MCP.Shared project with OutputTruncationHelper (threshold 15K) - Remove OutputTruncationHelper from Proxy/Helpers (no longer needed there) - Add NotifySilentResultReady() for small known-output internal paths - Update project references and solution file - Move tests to Tests/Unit/Shared/ --- PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj | 4 ++++ PowerShell.MCP.Proxy/Tools/PowerShellTools.cs | 1 + .../OutputTruncationHelper.cs | 4 ++-- .../PowerShell.MCP.Shared.csproj | 10 ++++++++++ PowerShell.MCP.sln | 14 ++++++++++++++ PowerShell.MCP/PowerShell.MCP.csproj | 4 ++++ PowerShell.MCP/Resources/MCPPollingEngine.ps1 | 2 +- .../Services/PowerShellCommunication.cs | 15 ++++++++++++++- Tests/PowerShell.MCP.Tests.csproj | 1 + .../OutputTruncationHelperTests.cs | 4 ++-- 10 files changed, 53 insertions(+), 6 deletions(-) rename {PowerShell.MCP.Proxy/Helpers => PowerShell.MCP.Shared}/OutputTruncationHelper.cs (98%) create mode 100644 PowerShell.MCP.Shared/PowerShell.MCP.Shared.csproj rename Tests/Unit/{Proxy => Shared}/OutputTruncationHelperTests.cs (99%) diff --git a/PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj b/PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj index 21b83f3..59d9d8e 100644 --- a/PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj +++ b/PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj @@ -66,4 +66,8 @@ + + + + diff --git a/PowerShell.MCP.Proxy/Tools/PowerShellTools.cs b/PowerShell.MCP.Proxy/Tools/PowerShellTools.cs index 205c0a3..2dbe8dc 100644 --- a/PowerShell.MCP.Proxy/Tools/PowerShellTools.cs +++ b/PowerShell.MCP.Proxy/Tools/PowerShellTools.cs @@ -4,6 +4,7 @@ using PowerShell.MCP.Proxy.Services; using PowerShell.MCP.Proxy.Models; using System.Text.Json; +using PowerShell.MCP.Shared; using PowerShell.MCP.Proxy.Helpers; namespace PowerShell.MCP.Proxy.Tools; diff --git a/PowerShell.MCP.Proxy/Helpers/OutputTruncationHelper.cs b/PowerShell.MCP.Shared/OutputTruncationHelper.cs similarity index 98% rename from PowerShell.MCP.Proxy/Helpers/OutputTruncationHelper.cs rename to PowerShell.MCP.Shared/OutputTruncationHelper.cs index c78b456..a2c606c 100644 --- a/PowerShell.MCP.Proxy/Helpers/OutputTruncationHelper.cs +++ b/PowerShell.MCP.Shared/OutputTruncationHelper.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace PowerShell.MCP.Proxy.Helpers; +namespace PowerShell.MCP.Shared; /// /// Truncates large command output to preserve AI context window budget. @@ -8,7 +8,7 @@ namespace PowerShell.MCP.Proxy.Helpers; /// public static class OutputTruncationHelper { - internal const int TruncationThreshold = 5_000; + internal const int TruncationThreshold = 15_000; internal const int PreviewHeadSize = 1000; internal const int PreviewTailSize = 1000; diff --git a/PowerShell.MCP.Shared/PowerShell.MCP.Shared.csproj b/PowerShell.MCP.Shared/PowerShell.MCP.Shared.csproj new file mode 100644 index 0000000..8542b2b --- /dev/null +++ b/PowerShell.MCP.Shared/PowerShell.MCP.Shared.csproj @@ -0,0 +1,10 @@ + + + net8.0;net9.0 + enable + enable + + + + + diff --git a/PowerShell.MCP.sln b/PowerShell.MCP.sln index 8705815..2c9e502 100644 --- a/PowerShell.MCP.sln +++ b/PowerShell.MCP.sln @@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerShell.MCP.Tests", "Tests\PowerShell.MCP.Tests.csproj", "{115EE524-BDC8-496D-BF2B-E851A4E0CF19}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerShell.MCP.Shared", "PowerShell.MCP.Shared\PowerShell.MCP.Shared.csproj", "{E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -57,6 +59,18 @@ Global {115EE524-BDC8-496D-BF2B-E851A4E0CF19}.Release|x64.Build.0 = Release|Any CPU {115EE524-BDC8-496D-BF2B-E851A4E0CF19}.Release|x86.ActiveCfg = Release|Any CPU {115EE524-BDC8-496D-BF2B-E851A4E0CF19}.Release|x86.Build.0 = Release|Any CPU + {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Debug|x64.ActiveCfg = Debug|Any CPU + {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Debug|x64.Build.0 = Debug|Any CPU + {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Debug|x86.ActiveCfg = Debug|Any CPU + {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Debug|x86.Build.0 = Debug|Any CPU + {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Release|Any CPU.Build.0 = Release|Any CPU + {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Release|x64.ActiveCfg = Release|Any CPU + {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Release|x64.Build.0 = Release|Any CPU + {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Release|x86.ActiveCfg = Release|Any CPU + {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/PowerShell.MCP/PowerShell.MCP.csproj b/PowerShell.MCP/PowerShell.MCP.csproj index 30e874e..a1509ed 100644 --- a/PowerShell.MCP/PowerShell.MCP.csproj +++ b/PowerShell.MCP/PowerShell.MCP.csproj @@ -45,4 +45,8 @@ + + + + diff --git a/PowerShell.MCP/Resources/MCPPollingEngine.ps1 b/PowerShell.MCP/Resources/MCPPollingEngine.ps1 index 9197dc2..66b3533 100644 --- a/PowerShell.MCP/Resources/MCPPollingEngine.ps1 +++ b/PowerShell.MCP/Resources/MCPPollingEngine.ps1 @@ -534,7 +534,7 @@ if (-not (Test-Path Variable:global:McpTimer)) { if ($null -eq $mcpOutput) { $mcpOutput = "Command execution completed" } - [PowerShell.MCP.Services.PowerShellCommunication]::NotifyResultReady($mcpOutput) + [PowerShell.MCP.Services.PowerShellCommunication]::NotifySilentResultReady($mcpOutput) } } } | Out-Null diff --git a/PowerShell.MCP/Services/PowerShellCommunication.cs b/PowerShell.MCP/Services/PowerShellCommunication.cs index a1e8113..f82bc86 100644 --- a/PowerShell.MCP/Services/PowerShellCommunication.cs +++ b/PowerShell.MCP/Services/PowerShellCommunication.cs @@ -1,3 +1,5 @@ +using PowerShell.MCP.Shared; + namespace PowerShell.MCP.Services; /// @@ -17,7 +19,18 @@ public static void NotifyResultReady(string result) // Capture cache flag before AddToCache resets it _resultShouldCache = ExecutionState.ShouldCacheOutput; - // Always add to cache + // Truncate large output before caching to reduce pipe transfer and memory overhead + var output = OutputTruncationHelper.TruncateIfNeeded(result); + ExecutionState.AddToCache(output); + ExecutionState.CompleteExecution(); + _resultReadyEvent.Set(); + } + + /// + /// Silent command completed — no truncation (internal use, small known outputs) + /// + public static void NotifySilentResultReady(string result) + { ExecutionState.AddToCache(result); ExecutionState.CompleteExecution(); _resultReadyEvent.Set(); diff --git a/Tests/PowerShell.MCP.Tests.csproj b/Tests/PowerShell.MCP.Tests.csproj index 9d383a7..6d298a6 100644 --- a/Tests/PowerShell.MCP.Tests.csproj +++ b/Tests/PowerShell.MCP.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/Tests/Unit/Proxy/OutputTruncationHelperTests.cs b/Tests/Unit/Shared/OutputTruncationHelperTests.cs similarity index 99% rename from Tests/Unit/Proxy/OutputTruncationHelperTests.cs rename to Tests/Unit/Shared/OutputTruncationHelperTests.cs index 96124bd..8ea093c 100644 --- a/Tests/Unit/Proxy/OutputTruncationHelperTests.cs +++ b/Tests/Unit/Shared/OutputTruncationHelperTests.cs @@ -1,7 +1,7 @@ -using PowerShell.MCP.Proxy.Helpers; +using PowerShell.MCP.Shared; using Xunit; -namespace PowerShell.MCP.Tests.Unit.Proxy; +namespace PowerShell.MCP.Tests.Unit.Shared; public class OutputTruncationHelperTests : IDisposable { From 1573b11af8821e8f26afd6d19e82f155ca250b4b Mon Sep 17 00:00:00 2001 From: ricky Date: Sun, 15 Feb 2026 17:28:52 +0800 Subject: [PATCH 4/4] Remove PowerShell.MCP.Shared project, keep truncation in DLL only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Truncation in NotifyResultReady() means the Proxy already receives truncated output, so the 12 TruncateIfNeeded calls on the Proxy side and the Shared project are unnecessary. - Move OutputTruncationHelper into PowerShell.MCP (DLL project) - Remove all TruncateIfNeeded calls from PowerShellTools.cs - Remove PowerShell.MCP.Shared project and all references - Fix truncation message: Get-Content → Show-TextFiles (avoids re-truncation loop) - Strengthen newline alignment test assertions to verify actual boundary positions instead of vacuously passing on metadata newlines --- .../PowerShell.MCP.Proxy.csproj | 8 ---- PowerShell.MCP.Proxy/Tools/PowerShellTools.cs | 25 ++++++------ .../PowerShell.MCP.Shared.csproj | 10 ----- PowerShell.MCP.sln | 14 ------- .../OutputTruncationHelper.cs | 4 +- PowerShell.MCP/PowerShell.MCP.csproj | 8 ++-- .../Services/PowerShellCommunication.cs | 2 - Tests/PowerShell.MCP.Tests.csproj | 1 - .../OutputTruncationHelperTests.cs | 39 +++++++++++-------- 9 files changed, 41 insertions(+), 70 deletions(-) delete mode 100644 PowerShell.MCP.Shared/PowerShell.MCP.Shared.csproj rename {PowerShell.MCP.Shared => PowerShell.MCP}/OutputTruncationHelper.cs (97%) rename Tests/Unit/{Shared => Core}/OutputTruncationHelperTests.cs (85%) diff --git a/PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj b/PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj index 59d9d8e..804f937 100644 --- a/PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj +++ b/PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj @@ -24,11 +24,6 @@ false - - - - - @@ -67,7 +62,4 @@ - - - diff --git a/PowerShell.MCP.Proxy/Tools/PowerShellTools.cs b/PowerShell.MCP.Proxy/Tools/PowerShellTools.cs index 2dbe8dc..7b8ad4d 100644 --- a/PowerShell.MCP.Proxy/Tools/PowerShellTools.cs +++ b/PowerShell.MCP.Proxy/Tools/PowerShellTools.cs @@ -4,7 +4,6 @@ using PowerShell.MCP.Proxy.Services; using PowerShell.MCP.Proxy.Models; using System.Text.Json; -using PowerShell.MCP.Shared; using PowerShell.MCP.Proxy.Helpers; namespace PowerShell.MCP.Proxy.Tools; @@ -104,7 +103,7 @@ public static async Task GetCurrentLocation( response.Append(completedOutputs); } response.Append(result); - return OutputTruncationHelper.TruncateIfNeeded(response.ToString()); + return response.ToString(); } catch (Exception ex) { @@ -214,7 +213,7 @@ public static async Task InvokeExpression( response.AppendLine(); response.Append(completedOutputs); } - return OutputTruncationHelper.TruncateIfNeeded(response.ToString()); + return response.ToString(); } // Console switched - get location (DLL will automatically include its own cached outputs) @@ -248,7 +247,7 @@ public static async Task InvokeExpression( response.AppendLine(); response.Append(completedOutputs); } - return OutputTruncationHelper.TruncateIfNeeded(response.ToString()); + return response.ToString(); } // Check for local variable assignments without scope prefix @@ -324,7 +323,7 @@ public static async Task InvokeExpression( busyResponse.AppendLine(); busyResponse.Append(completedOutputs); } - return OutputTruncationHelper.TruncateIfNeeded(busyResponse.ToString()); + return busyResponse.ToString(); } break; @@ -376,7 +375,7 @@ public static async Task InvokeExpression( } timeoutResponse.AppendLine(); timeoutResponse.Append("Use wait_for_completion tool to wait and retrieve the result."); - return OutputTruncationHelper.TruncateIfNeeded(timeoutResponse.ToString()); + return timeoutResponse.ToString(); case PipeStatus.Completed: // Result was cached - return status @@ -404,10 +403,10 @@ public static async Task InvokeExpression( cachedResponse.AppendLine(cachedStatusLine); cachedResponse.AppendLine(); cachedResponse.Append("Result cached. Will be returned on next tool call."); - return OutputTruncationHelper.TruncateIfNeeded(cachedResponse.ToString()); + return cachedResponse.ToString(); case "error": - return OutputTruncationHelper.TruncateIfNeeded(jsonResponse.Message ?? $"Error from PowerShell.MCP module: {jsonResponse.Error}"); + return jsonResponse.Message ?? $"Error from PowerShell.MCP module: {jsonResponse.Error}"; case "success": // Normal completion - use body as result @@ -455,7 +454,7 @@ public static async Task InvokeExpression( successResponse.AppendLine(); successResponse.Append(output); } - return OutputTruncationHelper.TruncateIfNeeded(successResponse.ToString()); + return successResponse.ToString(); } } } @@ -466,7 +465,7 @@ public static async Task InvokeExpression( } // Fallback: return result as-is (shouldn't happen with new DLL) - return OutputTruncationHelper.TruncateIfNeeded(result); + return result; } catch (Exception ex) { @@ -654,7 +653,7 @@ private static string BuildWaitResponse(List closedConsoleMessages, stri return "No busy consoles or cached results."; } - return OutputTruncationHelper.TruncateIfNeeded(response.ToString()); + return response.ToString(); } [McpServerTool] @@ -711,7 +710,7 @@ await powerShellService.ExecuteSilentAsync( reuseResponse.AppendLine("ℹ️ Did not launch a new console. An existing standby console is available and will be reused. To force a new console, provide the reason parameter."); reuseResponse.AppendLine(); reuseResponse.Append(reuseLocationResult); - return OutputTruncationHelper.TruncateIfNeeded(reuseResponse.ToString()); + return reuseResponse.ToString(); } // No standby console found, fall through to create a new one } @@ -753,7 +752,7 @@ await powerShellService.ExecuteSilentAsync( response.AppendLine("PowerShell console started successfully with PowerShell.MCP module imported."); response.AppendLine(); response.Append(startResult); - return OutputTruncationHelper.TruncateIfNeeded(response.ToString()); + return response.ToString(); } /// diff --git a/PowerShell.MCP.Shared/PowerShell.MCP.Shared.csproj b/PowerShell.MCP.Shared/PowerShell.MCP.Shared.csproj deleted file mode 100644 index 8542b2b..0000000 --- a/PowerShell.MCP.Shared/PowerShell.MCP.Shared.csproj +++ /dev/null @@ -1,10 +0,0 @@ - - - net8.0;net9.0 - enable - enable - - - - - diff --git a/PowerShell.MCP.sln b/PowerShell.MCP.sln index 2c9e502..8705815 100644 --- a/PowerShell.MCP.sln +++ b/PowerShell.MCP.sln @@ -11,8 +11,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerShell.MCP.Tests", "Tests\PowerShell.MCP.Tests.csproj", "{115EE524-BDC8-496D-BF2B-E851A4E0CF19}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerShell.MCP.Shared", "PowerShell.MCP.Shared\PowerShell.MCP.Shared.csproj", "{E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -59,18 +57,6 @@ Global {115EE524-BDC8-496D-BF2B-E851A4E0CF19}.Release|x64.Build.0 = Release|Any CPU {115EE524-BDC8-496D-BF2B-E851A4E0CF19}.Release|x86.ActiveCfg = Release|Any CPU {115EE524-BDC8-496D-BF2B-E851A4E0CF19}.Release|x86.Build.0 = Release|Any CPU - {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Debug|x64.ActiveCfg = Debug|Any CPU - {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Debug|x64.Build.0 = Debug|Any CPU - {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Debug|x86.ActiveCfg = Debug|Any CPU - {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Debug|x86.Build.0 = Debug|Any CPU - {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Release|Any CPU.Build.0 = Release|Any CPU - {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Release|x64.ActiveCfg = Release|Any CPU - {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Release|x64.Build.0 = Release|Any CPU - {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Release|x86.ActiveCfg = Release|Any CPU - {E4A3F5C1-B0A2-4D6E-9F8B-7C2D1A0E3B5F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/PowerShell.MCP.Shared/OutputTruncationHelper.cs b/PowerShell.MCP/OutputTruncationHelper.cs similarity index 97% rename from PowerShell.MCP.Shared/OutputTruncationHelper.cs rename to PowerShell.MCP/OutputTruncationHelper.cs index a2c606c..54ea749 100644 --- a/PowerShell.MCP.Shared/OutputTruncationHelper.cs +++ b/PowerShell.MCP/OutputTruncationHelper.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace PowerShell.MCP.Shared; +namespace PowerShell.MCP; /// /// Truncates large command output to preserve AI context window budget. @@ -54,7 +54,7 @@ public static string TruncateIfNeeded(string output, string? outputDirectory = n if (filePath != null) { sb.AppendLine($"Output too large ({output.Length} characters). Full output saved to: {filePath}"); - sb.AppendLine($"Use invoke_expression('Get-Content \"{filePath}\"') or Read tool to access the full output."); + sb.AppendLine($"Use invoke_expression('Show-TextFiles \"{filePath}\" -Contains \"search term\"') or -Pattern \"regex\" to search the output."); } else { diff --git a/PowerShell.MCP/PowerShell.MCP.csproj b/PowerShell.MCP/PowerShell.MCP.csproj index a1509ed..e9ea058 100644 --- a/PowerShell.MCP/PowerShell.MCP.csproj +++ b/PowerShell.MCP/PowerShell.MCP.csproj @@ -8,6 +8,10 @@ $(NoWarn);NETSDK1206 + + + + @@ -45,8 +49,4 @@ - - - - diff --git a/PowerShell.MCP/Services/PowerShellCommunication.cs b/PowerShell.MCP/Services/PowerShellCommunication.cs index f82bc86..4e7e3b8 100644 --- a/PowerShell.MCP/Services/PowerShellCommunication.cs +++ b/PowerShell.MCP/Services/PowerShellCommunication.cs @@ -1,5 +1,3 @@ -using PowerShell.MCP.Shared; - namespace PowerShell.MCP.Services; /// diff --git a/Tests/PowerShell.MCP.Tests.csproj b/Tests/PowerShell.MCP.Tests.csproj index 6d298a6..9d383a7 100644 --- a/Tests/PowerShell.MCP.Tests.csproj +++ b/Tests/PowerShell.MCP.Tests.csproj @@ -22,7 +22,6 @@ - diff --git a/Tests/Unit/Shared/OutputTruncationHelperTests.cs b/Tests/Unit/Core/OutputTruncationHelperTests.cs similarity index 85% rename from Tests/Unit/Shared/OutputTruncationHelperTests.cs rename to Tests/Unit/Core/OutputTruncationHelperTests.cs index 8ea093c..7a7173d 100644 --- a/Tests/Unit/Shared/OutputTruncationHelperTests.cs +++ b/Tests/Unit/Core/OutputTruncationHelperTests.cs @@ -1,7 +1,7 @@ -using PowerShell.MCP.Shared; +using PowerShell.MCP; using Xunit; -namespace PowerShell.MCP.Tests.Unit.Shared; +namespace PowerShell.MCP.Tests.Unit.Core; public class OutputTruncationHelperTests : IDisposable { @@ -112,16 +112,21 @@ public void TruncateIfNeeded_OverThreshold_HeadAlignsToNewline() var result = OutputTruncationHelper.TruncateIfNeeded(output, _testDir); - // The head preview should end at or near the newline, not at PreviewHeadSize - // Extract the head preview section (after the metadata, before "truncated") - var truncatedIndex = result.IndexOf("truncated"); + // Extract the head content: appears after "--- Preview (first ~1000 chars) ---" + newline + var headMarker = "--- Preview (first ~1000 chars) ---" + Environment.NewLine; + var headStart = result.IndexOf(headMarker); + Assert.True(headStart >= 0, "Should contain head preview marker"); + var headContentStart = headStart + headMarker.Length; + + var truncatedMarker = "--- truncated"; + var truncatedIndex = result.IndexOf(truncatedMarker, headContentStart); Assert.True(truncatedIndex > 0, "Should contain 'truncated' marker"); - // The head preview area should not contain content beyond the newline - // (it should have been cut at the \n boundary) - var previewSection = result.Substring(0, truncatedIndex); - // Head portion should be shorter than PreviewHeadSize since it aligned to newline - Assert.Contains("\n", previewSection); + // Head content is between the marker and the truncated line (minus trailing newline from AppendLine) + var headContent = result.Substring(headContentStart, truncatedIndex - headContentStart) + .TrimEnd('\n', '\r'); + // Head should have been cut at the newline (position 950+1 = 951 chars) + Assert.Equal(newlinePos + 1, headContent.Length + 1); // +1 because the \n is the cut point } [Fact] @@ -140,13 +145,15 @@ public void TruncateIfNeeded_OverThreshold_TailAlignsToNewline() var result = OutputTruncationHelper.TruncateIfNeeded(output, _testDir); - // The tail preview should start at the newline boundary - var lastPreviewMarker = result.LastIndexOf("Preview (last"); - Assert.True(lastPreviewMarker > 0, "Should contain tail preview marker"); + // Extract the tail content: appears after "--- Preview (last ~1000 chars) ---" + newline + var tailMarker = "--- Preview (last ~1000 chars) ---" + Environment.NewLine; + var tailMarkerPos = result.LastIndexOf(tailMarker); + Assert.True(tailMarkerPos >= 0, "Should contain tail preview marker"); + var tailContent = result.Substring(tailMarkerPos + tailMarker.Length); - // The tail section after the marker should start near the newline - var tailSection = result.Substring(lastPreviewMarker); - Assert.Contains("\n", tailSection); + // Tail should start at the character after the planted newline + var expectedTail = output[(newlinePos + 1)..]; + Assert.Equal(expectedTail, tailContent); } [Fact]