Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions NanoAgent.Tests/Application/Tools/FileWriteToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,40 @@ [new WorkspaceFileEditState("README.md", exists: false, content: null)],
transaction!.Description.Should().Be("file_write (README.md)");
}

[Fact]
public async Task ExecuteAsync_Should_PassEmptyContentToWorkspaceService()
{
Mock<IWorkspaceFileService> workspaceFileService = new(MockBehavior.Strict);
workspaceFileService
.Setup(service => service.WriteFileWithTrackingAsync(
".gitkeep",
string.Empty,
true,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkspaceFileWriteExecutionResult(
new WorkspaceFileWriteResult(
".gitkeep",
false,
0,
0,
0,
[],
0),
new WorkspaceFileEditTransaction(
"file_write (.gitkeep)",
[new WorkspaceFileEditState(".gitkeep", exists: false, content: null)],
[new WorkspaceFileEditState(".gitkeep", exists: true, content: string.Empty)])));

FileWriteTool sut = new(workspaceFileService.Object);

ToolResult result = await sut.ExecuteAsync(
CreateContext("""{ "path": ".gitkeep", "content": "" }"""),
CancellationToken.None);

result.Status.Should().Be(ToolResultStatus.Success);
workspaceFileService.VerifyAll();
}

private static ToolExecutionContext CreateContext(
string argumentsJson,
ReplSessionContext? session = null)
Expand Down
39 changes: 39 additions & 0 deletions NanoAgent.Tests/Infrastructure/Secrets/ProcessRunnerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using NanoAgent.Infrastructure.Secrets;
using FluentAssertions;

namespace NanoAgent.Tests.Infrastructure.Secrets;

public sealed class ProcessRunnerTests
{
[Fact]
public async Task RunAsync_Should_CapCapturedStandardOutputAndError()
{
ProcessExecutionRequest request = OperatingSystem.IsWindows()
? new ProcessExecutionRequest(
"powershell",
[
"-NoProfile",
"-NonInteractive",
"-Command",
"[Console]::Out.Write(('o' * 20000)); [Console]::Error.Write(('e' * 20000))"
],
MaxOutputCharacters: 128)
: new ProcessExecutionRequest(
"/bin/sh",
[
"-c",
"printf '%*s' 20000 '' | tr ' ' o; printf '%*s' 20000 '' | tr ' ' e >&2"
],
MaxOutputCharacters: 128);

ProcessExecutionResult result = await new ProcessRunner().RunAsync(
request,
CancellationToken.None);

result.ExitCode.Should().Be(0);
result.StandardOutput.Length.Should().BeLessThanOrEqualTo(128);
result.StandardError.Length.Should().BeLessThanOrEqualTo(128);
result.StandardOutput.Should().EndWith("...");
result.StandardError.Should().EndWith("...");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ await sut.ExecuteAsync(
processRunner.Requests[0].FileName.Should().Be("/bin/bash");
processRunner.Requests[0].Arguments.Should().Equal("-lc", "node -v && npm -v");
processRunner.Requests[0].WorkingDirectory.Should().Be(Path.Combine(_workspaceRoot, "src"));
processRunner.Requests[0].MaxOutputCharacters.Should().Be(8000);
}

[Fact]
Expand All @@ -65,6 +66,7 @@ await sut.ExecuteAsync(
processRunner.Requests.Should().ContainSingle();
ProcessExecutionRequest request = processRunner.Requests[0];
request.FileName.Should().Be("powershell");
request.MaxOutputCharacters.Should().Be(8000);
request.Arguments.Should().Contain("-Command");
request.Arguments[^1].Should().Contain("Invoke-NanoSegment");
request.Arguments[^1].Should().Contain("FromBase64String");
Expand Down
103 changes: 103 additions & 0 deletions NanoAgent.Tests/Infrastructure/Tools/WorkspaceFileServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,61 @@ await File.WriteAllTextAsync(
new WorkspaceFileWritePreviewLine(3, "context", "}"));
}

[Fact]
public async Task WriteFileAsync_Should_AllowEmptyContent()
{
WorkspaceFileService sut = CreateSut();

WorkspaceFileWriteResult result = await sut.WriteFileAsync(
".gitkeep",
string.Empty,
overwrite: true,
CancellationToken.None);

result.CharacterCount.Should().Be(0);
result.AddedLineCount.Should().Be(0);
result.PreviewLines.Should().BeEmpty();
File.ReadAllText(Path.Combine(_workspaceRoot, ".gitkeep"))
.Should()
.BeEmpty();
}

[Fact]
public async Task WriteFileAsync_Should_TruncateExistingFileToEmptyContent()
{
WorkspaceFileService sut = CreateSut();
string filePath = Path.Combine(_workspaceRoot, "settings.json");
await File.WriteAllTextAsync(filePath, "{}\n", CancellationToken.None);

WorkspaceFileWriteResult result = await sut.WriteFileAsync(
"settings.json",
string.Empty,
overwrite: true,
CancellationToken.None);

result.OverwroteExistingFile.Should().BeTrue();
result.CharacterCount.Should().Be(0);
File.ReadAllText(filePath).Should().BeEmpty();
}

[Fact]
public async Task WriteFileAsync_Should_WriteUtf8WithoutBom()
{
WorkspaceFileService sut = CreateSut();

await sut.WriteFileAsync(
"script.sh",
"#!/bin/sh\necho hi\n",
overwrite: true,
CancellationToken.None);

byte[] bytes = await File.ReadAllBytesAsync(
Path.Combine(_workspaceRoot, "script.sh"),
CancellationToken.None);

bytes.Take(3).Should().NotEqual(new byte[] { 0xEF, 0xBB, 0xBF });
}

[Fact]
public async Task ReadFileAsync_Should_ReadFileContent()
{
Expand Down Expand Up @@ -149,6 +204,54 @@ class Program
.Should().Be("remember the tests");
}

[Fact]
public async Task ApplyPatchAsync_Should_AddFinalNewline_When_RemovedLineHadNoNewlineMarker()
{
WorkspaceFileService sut = CreateSut();
string filePath = Path.Combine(_workspaceRoot, "settings.json");
await File.WriteAllTextAsync(filePath, "{}", CancellationToken.None);

await sut.ApplyPatchAsync(
"""
*** Begin Patch
*** Update File: settings.json
@@
-{}
\ No newline at end of file
+{}
*** End Patch
""",
CancellationToken.None);

(await File.ReadAllTextAsync(filePath, CancellationToken.None))
.Should()
.Be("{}\n");
}

[Fact]
public async Task ApplyPatchAsync_Should_RemoveFinalNewline_When_AddedLineHasNoNewlineMarker()
{
WorkspaceFileService sut = CreateSut();
string filePath = Path.Combine(_workspaceRoot, "settings.json");
await File.WriteAllTextAsync(filePath, "{}\n", CancellationToken.None);

await sut.ApplyPatchAsync(
"""
*** Begin Patch
*** Update File: settings.json
@@
-{}
+{}
\ No newline at end of file
*** End Patch
""",
CancellationToken.None);

(await File.ReadAllTextAsync(filePath, CancellationToken.None))
.Should()
.Be("{}");
}

[Fact]
public async Task WriteFileWithTrackingAsync_Should_ReturnUndoableBeforeAndAfterStates()
{
Expand Down
3 changes: 2 additions & 1 deletion NanoAgent/Infrastructure/Secrets/ProcessExecutionRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ internal sealed record ProcessExecutionRequest(
string FileName,
IReadOnlyList<string> Arguments,
string? StandardInput = null,
string? WorkingDirectory = null);
string? WorkingDirectory = null,
int? MaxOutputCharacters = null);
77 changes: 75 additions & 2 deletions NanoAgent/Infrastructure/Secrets/ProcessRunner.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Text;

namespace NanoAgent.Infrastructure.Secrets;

Expand Down Expand Up @@ -37,8 +38,14 @@ public async Task<ProcessExecutionResult> RunAsync(

process.Start();

Task<string> standardOutputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
Task<string> standardErrorTask = process.StandardError.ReadToEndAsync(cancellationToken);
Task<string> standardOutputTask = ReadToEndCappedAsync(
process.StandardOutput,
request.MaxOutputCharacters,
cancellationToken);
Task<string> standardErrorTask = ReadToEndCappedAsync(
process.StandardError,
request.MaxOutputCharacters,
cancellationToken);

if (request.StandardInput is not null)
{
Expand Down Expand Up @@ -76,4 +83,70 @@ private static void TryKillProcess(Process process)
{
}
}

private static async Task<string> ReadToEndCappedAsync(
TextReader reader,
int? maxCharacters,
CancellationToken cancellationToken)
{
const int BufferSize = 4096;

if (maxCharacters is <= 0)
{
await DrainAsync(reader, cancellationToken);
return string.Empty;
}

char[] buffer = new char[BufferSize];
StringBuilder builder = maxCharacters is null
? new StringBuilder()
: new StringBuilder(Math.Min(maxCharacters.Value, BufferSize));
bool truncated = false;

while (true)
{
int read = await reader.ReadAsync(
buffer.AsMemory(0, buffer.Length),
cancellationToken);
if (read == 0)
{
break;
}

if (maxCharacters is null)
{
builder.Append(buffer, 0, read);
continue;
}

int remaining = maxCharacters.Value - builder.Length;
if (remaining <= 0)
{
truncated = true;
continue;
}

int charactersToAppend = Math.Min(read, remaining);
builder.Append(buffer, 0, charactersToAppend);
truncated |= charactersToAppend < read;
}

if (truncated && maxCharacters is > 3)
{
builder.Length = Math.Min(builder.Length, maxCharacters.Value - 3);
builder.Append("...");
}

return builder.ToString();
}

private static async Task DrainAsync(
TextReader reader,
CancellationToken cancellationToken)
{
char[] buffer = new char[4096];
while (await reader.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken) > 0)
{
}
}
}
6 changes: 4 additions & 2 deletions NanoAgent/Infrastructure/Tools/ShellCommandService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@ public async Task<ShellCommandExecutionResult> ExecuteAsync(
? new ProcessExecutionRequest(
"powershell",
["-NoProfile", "-NonInteractive", "-Command", commandText],
WorkingDirectory: workingDirectory)
WorkingDirectory: workingDirectory,
MaxOutputCharacters: MaxOutputCharacters)
: new ProcessExecutionRequest(
"/bin/bash",
["-lc", request.Command],
WorkingDirectory: workingDirectory);
WorkingDirectory: workingDirectory,
MaxOutputCharacters: MaxOutputCharacters);

ProcessExecutionResult result = await _processRunner.RunAsync(
processRequest,
Expand Down
Loading
Loading