Skip to content

Commit a15103d

Browse files
committed
Added persisted session WorkingDirectory
1 parent 3afc260 commit a15103d

16 files changed

Lines changed: 629 additions & 41 deletions

‎NanoAgent.Tests/Application/Models/ReplSessionContextTests.cs‎

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,12 +214,90 @@ public void SessionState_Should_PersistFileEditAndTerminalContext_InSnapshot()
214214
resumedSession.CreateStatefulContextPrompt().Should().Contain("dotnet test NanoAgent.slnx");
215215
}
216216

217-
private static ReplSessionContext CreateSession()
217+
[Fact]
218+
public void ResolvePathFromWorkingDirectory_Should_UseCurrentSessionDirectory()
219+
{
220+
string workspaceRoot = Path.Combine(
221+
Path.GetTempPath(),
222+
$"NanoAgent-SessionCwd-{Guid.NewGuid():N}");
223+
Directory.CreateDirectory(Path.Combine(workspaceRoot, "src", "api"));
224+
225+
try
226+
{
227+
ReplSessionContext session = CreateSession(workspaceRoot);
228+
229+
session.TrySetWorkingDirectory("src", out string? error).Should().BeTrue(error);
230+
231+
session.WorkingDirectory.Should().Be("src");
232+
session.ResolvePathFromWorkingDirectory("Program.cs").Should().Be("src/Program.cs");
233+
session.ResolvePathFromWorkingDirectory("../README.md").Should().Be("README.md");
234+
235+
session.TrySetWorkingDirectory("api", out error).Should().BeTrue(error);
236+
237+
session.WorkingDirectory.Should().Be("src/api");
238+
}
239+
finally
240+
{
241+
if (Directory.Exists(workspaceRoot))
242+
{
243+
Directory.Delete(workspaceRoot, recursive: true);
244+
}
245+
}
246+
}
247+
248+
[Fact]
249+
public void SessionState_Should_PersistWorkingDirectory_InSnapshot()
250+
{
251+
string workspaceRoot = Path.Combine(
252+
Path.GetTempPath(),
253+
$"NanoAgent-SessionStateCwd-{Guid.NewGuid():N}");
254+
Directory.CreateDirectory(Path.Combine(workspaceRoot, "ToDoApp"));
255+
256+
try
257+
{
258+
ReplSessionContext session = CreateSession(workspaceRoot);
259+
session.TrySetWorkingDirectory("ToDoApp", out string? error).Should().BeTrue(error);
260+
261+
ConversationSectionSnapshot snapshot = session.CreateSectionSnapshot(
262+
session.SectionCreatedAtUtc.AddMinutes(1));
263+
ReplSessionContext resumedSession = new(
264+
"NanoAgent",
265+
snapshot.ProviderProfile,
266+
snapshot.ActiveModelId,
267+
snapshot.AvailableModelIds,
268+
snapshot.SectionId,
269+
snapshot.Title,
270+
snapshot.CreatedAtUtc,
271+
snapshot.UpdatedAtUtc,
272+
snapshot.TotalEstimatedOutputTokens,
273+
snapshot.Turns,
274+
snapshot.PendingExecutionPlan,
275+
isResumedSection: true,
276+
agentProfile: BuiltInAgentProfiles.Resolve(snapshot.AgentProfileName),
277+
reasoningEffort: snapshot.ReasoningEffort,
278+
sessionState: snapshot.SessionState,
279+
workspacePath: workspaceRoot);
280+
281+
snapshot.SessionState.WorkingDirectory.Should().Be("ToDoApp");
282+
resumedSession.WorkingDirectory.Should().Be("ToDoApp");
283+
resumedSession.CreateStatefulContextPrompt().Should().Contain("Current working directory: ToDoApp");
284+
}
285+
finally
286+
{
287+
if (Directory.Exists(workspaceRoot))
288+
{
289+
Directory.Delete(workspaceRoot, recursive: true);
290+
}
291+
}
292+
}
293+
294+
private static ReplSessionContext CreateSession(string? workspacePath = null)
218295
{
219296
return new ReplSessionContext(
220297
new AgentProviderProfile(ProviderKind.OpenAiCompatible, "https://provider.example.com/v1"),
221298
"gpt-5-mini",
222-
["gpt-5-mini"]);
299+
["gpt-5-mini"],
300+
workspacePath: workspacePath);
223301
}
224302

225303
private static WorkspaceFileEditTransaction CreateTransaction(string description)

‎NanoAgent.Tests/Application/Permissions/ToolPermissionEvaluatorTests.cs‎

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,91 @@ public void Evaluate_Should_Deny_When_PathFallsOutsideAllowedRoot()
7575
result.ReasonCode.Should().Be("path_not_allowed");
7676
}
7777

78+
[Fact]
79+
public void Evaluate_Should_ResolveFilePathsFromSessionWorkingDirectory()
80+
{
81+
ReplSessionContext session = CreateSession(workspacePath: _workspaceRoot);
82+
session.TrySetWorkingDirectory("src", out string? error).Should().BeTrue(error);
83+
ToolPermissionEvaluator sut = new(
84+
new StubWorkspaceRootProvider(_workspaceRoot),
85+
CreatePermissionSettings());
86+
87+
PermissionEvaluationResult result = sut.Evaluate(
88+
new ToolPermissionPolicy
89+
{
90+
FilePaths =
91+
[
92+
new FilePathPermissionRule
93+
{
94+
ArgumentName = "path",
95+
Kind = ToolPathAccessKind.Read,
96+
AllowedRoots = ["."]
97+
}
98+
]
99+
},
100+
new PermissionEvaluationContext(CreateContext("""{ "path": "../docs/readme.md" }""", session)));
101+
102+
result.IsAllowed.Should().BeTrue();
103+
result.Request.Should().NotBeNull();
104+
result.Request!.Subjects.Should().Contain("docs/readme.md");
105+
}
106+
107+
[Fact]
108+
public void Evaluate_Should_DenyFilePathsOutsideWorkspace_When_ResolvedFromSessionWorkingDirectory()
109+
{
110+
ReplSessionContext session = CreateSession(workspacePath: _workspaceRoot);
111+
session.TrySetWorkingDirectory("src", out string? error).Should().BeTrue(error);
112+
ToolPermissionEvaluator sut = new(
113+
new StubWorkspaceRootProvider(_workspaceRoot),
114+
CreatePermissionSettings());
115+
116+
PermissionEvaluationResult result = sut.Evaluate(
117+
new ToolPermissionPolicy
118+
{
119+
FilePaths =
120+
[
121+
new FilePathPermissionRule
122+
{
123+
ArgumentName = "path",
124+
Kind = ToolPathAccessKind.Read,
125+
AllowedRoots = ["."]
126+
}
127+
]
128+
},
129+
new PermissionEvaluationContext(CreateContext("""{ "path": "../../outside.txt" }""", session)));
130+
131+
result.Decision.Should().Be(PermissionEvaluationDecision.Denied);
132+
result.ReasonCode.Should().Be("path_outside_workspace");
133+
}
134+
135+
[Fact]
136+
public void Evaluate_Should_ResolvePatchPathsFromSessionWorkingDirectory()
137+
{
138+
ReplSessionContext session = CreateSession(workspacePath: _workspaceRoot);
139+
session.TrySetWorkingDirectory("src", out string? error).Should().BeTrue(error);
140+
ToolPermissionEvaluator sut = new(
141+
new StubWorkspaceRootProvider(_workspaceRoot),
142+
CreatePermissionSettings());
143+
144+
PermissionEvaluationResult result = sut.Evaluate(
145+
new ToolPermissionPolicy
146+
{
147+
Patch = new PatchPermissionPolicy
148+
{
149+
PatchArgumentName = "patch",
150+
Kind = ToolPathAccessKind.Write,
151+
AllowedRoots = ["."]
152+
}
153+
},
154+
new PermissionEvaluationContext(CreateContext(
155+
"""{ "patch": "*** Begin Patch\n*** Update File: Program.cs\n@@\n-old\n+new\n*** End Patch" }""",
156+
session)));
157+
158+
result.IsAllowed.Should().BeTrue();
159+
result.Request.Should().NotBeNull();
160+
result.Request!.Subjects.Should().Contain("src/Program.cs");
161+
}
162+
78163
[Fact]
79164
public void Evaluate_Should_ReturnRequiresApproval_When_PolicyRequiresApproval()
80165
{
@@ -771,13 +856,16 @@ private static ToolExecutionContext CreateContext(
771856
executionPhase);
772857
}
773858

774-
private static ReplSessionContext CreateSession(IAgentProfile? agentProfile = null)
859+
private static ReplSessionContext CreateSession(
860+
IAgentProfile? agentProfile = null,
861+
string? workspacePath = null)
775862
{
776863
return new ReplSessionContext(
777864
new AgentProviderProfile(ProviderKind.OpenAi, null),
778865
"gpt-5-mini",
779866
["gpt-5-mini"],
780-
agentProfile);
867+
agentProfile,
868+
workspacePath: workspacePath);
781869
}
782870

783871
private static PermissionSettings CreatePermissionSettings(PermissionSettings? settings = null)

‎NanoAgent.Tests/Application/Tools/ApplyPatchToolTests.cs‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,62 @@ public async Task ExecuteAsync_Should_ReturnRetryGuidance_When_PatchFormatIsInva
9292
result.RenderPayload.Text.Should().Contain("final non-empty line must be exactly '*** End Patch'");
9393
}
9494

95+
[Fact]
96+
public async Task ExecuteAsync_Should_ResolvePatchPathsFromSessionWorkingDirectory()
97+
{
98+
string workspaceRoot = Path.Combine(
99+
Path.GetTempPath(),
100+
$"NanoAgent-PatchCwd-{Guid.NewGuid():N}");
101+
Directory.CreateDirectory(Path.Combine(workspaceRoot, "ToDoApp"));
102+
103+
try
104+
{
105+
ReplSessionContext session = TestSessionFactory.Create(workspaceRoot);
106+
session.TrySetWorkingDirectory("ToDoApp", out string? error).Should().BeTrue(error);
107+
108+
Mock<IWorkspaceFileService> workspaceFileService = new(MockBehavior.Strict);
109+
workspaceFileService
110+
.Setup(service => service.ApplyPatchWithTrackingAsync(
111+
"*** Begin Patch\n*** Update File: ToDoApp/Program.cs\n@@\n-old\n+new\n*** End Patch",
112+
It.IsAny<CancellationToken>()))
113+
.ReturnsAsync(new WorkspaceApplyPatchExecutionResult(
114+
new WorkspaceApplyPatchResult(
115+
1,
116+
1,
117+
1,
118+
[
119+
new WorkspaceApplyPatchFileResult(
120+
"ToDoApp/Program.cs",
121+
"update",
122+
null,
123+
1,
124+
1,
125+
[],
126+
0)
127+
]),
128+
new WorkspaceFileEditTransaction(
129+
"apply_patch (1 file)",
130+
[new WorkspaceFileEditState("ToDoApp/Program.cs", exists: true, content: "old")],
131+
[new WorkspaceFileEditState("ToDoApp/Program.cs", exists: true, content: "new")])));
132+
133+
ApplyPatchTool sut = new(workspaceFileService.Object);
134+
135+
ToolResult result = await sut.ExecuteAsync(
136+
CreateContext("""{ "patch": "*** Begin Patch\n*** Update File: Program.cs\n@@\n-old\n+new\n*** End Patch" }""", session),
137+
CancellationToken.None);
138+
139+
result.Status.Should().Be(ToolResultStatus.Success);
140+
workspaceFileService.VerifyAll();
141+
}
142+
finally
143+
{
144+
if (Directory.Exists(workspaceRoot))
145+
{
146+
Directory.Delete(workspaceRoot, recursive: true);
147+
}
148+
}
149+
}
150+
95151
private static ToolExecutionContext CreateContext(
96152
string argumentsJson,
97153
ReplSessionContext? session = null)

‎NanoAgent.Tests/Application/Tools/ShellCommandToolTests.cs‎

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,78 @@ public async Task ExecuteAsync_Should_RecordTerminalHistory_When_CommandRuns()
8787
session.SessionState.TerminalHistory[0].StandardOutput.Should().Be("Passed!");
8888
}
8989

90+
[Fact]
91+
public async Task ExecuteAsync_Should_UpdateSessionWorkingDirectory_ForFollowUpFileTools()
92+
{
93+
string workspaceRoot = Path.Combine(
94+
Path.GetTempPath(),
95+
$"NanoAgent-ShellCwd-{Guid.NewGuid():N}");
96+
Directory.CreateDirectory(Path.Combine(workspaceRoot, "ToDoApp"));
97+
98+
try
99+
{
100+
ReplSessionContext session = TestSessionFactory.Create(workspaceRoot);
101+
Mock<IShellCommandService> shellCommandService = new(MockBehavior.Strict);
102+
shellCommandService
103+
.Setup(service => service.ExecuteAsync(
104+
It.Is<ShellCommandExecutionRequest>(request =>
105+
request.Command == "cd ToDoApp" &&
106+
request.WorkingDirectory == "."),
107+
It.IsAny<CancellationToken>()))
108+
.ReturnsAsync(new ShellCommandExecutionResult(
109+
"cd ToDoApp",
110+
".",
111+
0,
112+
string.Empty,
113+
string.Empty));
114+
115+
ShellCommandTool shellTool = new(shellCommandService.Object);
116+
ToolResult shellResult = await shellTool.ExecuteAsync(
117+
CreateContext("""{ "command": "cd ToDoApp" }""", session),
118+
CancellationToken.None);
119+
120+
shellResult.Status.Should().Be(ToolResultStatus.Success);
121+
shellResult.Message.Should().Contain("Session working directory is now 'ToDoApp'");
122+
session.WorkingDirectory.Should().Be("ToDoApp");
123+
124+
Mock<IWorkspaceFileService> workspaceFileService = new(MockBehavior.Strict);
125+
workspaceFileService
126+
.Setup(service => service.WriteFileWithTrackingAsync(
127+
"ToDoApp/Program.cs",
128+
"class Program {}",
129+
true,
130+
It.IsAny<CancellationToken>()))
131+
.ReturnsAsync(new WorkspaceFileWriteExecutionResult(
132+
new WorkspaceFileWriteResult(
133+
"ToDoApp/Program.cs",
134+
false,
135+
16,
136+
1,
137+
0,
138+
[new WorkspaceFileWritePreviewLine(1, "add", "class Program {}")],
139+
0),
140+
new WorkspaceFileEditTransaction(
141+
"file_write (ToDoApp/Program.cs)",
142+
[new WorkspaceFileEditState("ToDoApp/Program.cs", exists: false, content: null)],
143+
[new WorkspaceFileEditState("ToDoApp/Program.cs", exists: true, content: "class Program {}")])));
144+
145+
FileWriteTool fileWriteTool = new(workspaceFileService.Object);
146+
ToolResult writeResult = await fileWriteTool.ExecuteAsync(
147+
CreateFileWriteContext("""{ "path": "Program.cs", "content": "class Program {}" }""", session),
148+
CancellationToken.None);
149+
150+
writeResult.Status.Should().Be(ToolResultStatus.Success);
151+
workspaceFileService.VerifyAll();
152+
}
153+
finally
154+
{
155+
if (Directory.Exists(workspaceRoot))
156+
{
157+
Directory.Delete(workspaceRoot, recursive: true);
158+
}
159+
}
160+
}
161+
90162
[Fact]
91163
public async Task ExecuteAsync_Should_ForwardSandboxEscalationArguments_When_Provided()
92164
{
@@ -178,13 +250,27 @@ [new ShellCommandTool(Mock.Of<IShellCommandService>())],
178250
]);
179251
}
180252

181-
private static ToolExecutionContext CreateContext(string argumentsJson)
253+
private static ToolExecutionContext CreateContext(
254+
string argumentsJson,
255+
ReplSessionContext? session = null)
182256
{
183257
using JsonDocument document = JsonDocument.Parse(argumentsJson);
184258
return new ToolExecutionContext(
185259
"call_1",
186260
"shell_command",
187261
document.RootElement.Clone(),
188-
TestSessionFactory.Create());
262+
session ?? TestSessionFactory.Create());
263+
}
264+
265+
private static ToolExecutionContext CreateFileWriteContext(
266+
string argumentsJson,
267+
ReplSessionContext session)
268+
{
269+
using JsonDocument document = JsonDocument.Parse(argumentsJson);
270+
return new ToolExecutionContext(
271+
"call_2",
272+
"file_write",
273+
document.RootElement.Clone(),
274+
session);
189275
}
190276
}

‎NanoAgent.Tests/Application/Tools/TestSessionFactory.cs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ namespace NanoAgent.Tests.Application.Tools;
55

66
internal static class TestSessionFactory
77
{
8-
public static ReplSessionContext Create()
8+
public static ReplSessionContext Create(string? workspacePath = null)
99
{
1010
return new ReplSessionContext(
1111
new AgentProviderProfile(ProviderKind.OpenAiCompatible, "https://provider.example.com/v1"),
1212
"gpt-5-mini",
13-
["gpt-5-mini", "gpt-4.1"]);
13+
["gpt-5-mini", "gpt-4.1"],
14+
workspacePath: workspacePath);
1415
}
1516
}

0 commit comments

Comments
 (0)