using System.Text; using System.Text.Json; using ClawdDotNet.Core.Files; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tools; using ClawdDotNet.Tools.FileRW; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Tools.Tests.FileRW; /// /// #719: FileRW is the main way into the workspaces — every change it makes lands in the /// history, with the agent and the run of the call. /// public sealed class FileHistoryRecordingTests : IDisposable { private readonly string _instance = Path.Combine(Path.GetTempPath(), "clawd-tests", "fh-" + Guid.NewGuid().ToString("N")[..8]); private readonly string _agents; private readonly FileHistory _history; private readonly FileRWTool _tool = new(); public FileHistoryRecordingTests() { _agents = Path.Combine(_instance, "Agents"); Directory.CreateDirectory(Path.Combine(_agents, "SharedWorkspace")); _history = new FileHistory(_agents, new SqliteStorage(Path.Combine(_instance, "state.db"))); } public void Dispose() { SqliteStorage.ReleasePooledConnections(_instance); try { Directory.Delete(_instance, recursive: true); } catch { /* cleanup is secondary */ } } private Task Run(string agentId, object input) { var personal = Path.Combine(_agents, "Agent-" + agentId, "Workspace"); Directory.CreateDirectory(personal); var context = new AgentToolContext(agentId, "inst", new Dictionary { ["sharedAccessLevel"] = "Admin" }, new NullStateStore(), NullLogger.Instance, CancellationToken.None, WorkspacePath: personal, SharedWorkspacePath: Path.Combine(_agents, "SharedWorkspace")) { History = _history, RunId = "run-" + agentId }; return _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context, CancellationToken.None); } private async Task> Journal() => (await _history.ListAsync(null, null, null, null, 100, default)).Reverse().ToList(); [Fact] public async Task Write_append_and_delete_are_recorded_with_agent_and_run() { await Run("a", new { action = "write", workspace = "shared", path = "notes/plan.md", content = "v1" }); await Run("b", new { action = "append", workspace = "shared", path = "notes/plan.md", content = "\n+b" }); await Run("c", new { action = "delete", workspace = "shared", path = "notes/plan.md" }); (await Journal()).Select(e => (e.Action, e.AgentId, e.RunId, e.Path, e.Workspace)).ShouldBe([ (FileAction.Create, "a", "run-a", "SharedWorkspace/notes/plan.md", "shared"), (FileAction.Append, "b", "run-b", "SharedWorkspace/notes/plan.md", "shared"), (FileAction.Delete, "c", "run-c", "SharedWorkspace/notes/plan.md", "shared") ]); } [Fact] public async Task What_a_delete_removed_can_be_read_back() { await Run("a", new { action = "write", workspace = "shared", path = "plan.md", content = "precious" }); await Run("b", new { action = "delete", workspace = "shared", path = "plan.md" }); var delete = (await Journal()).Last(); Encoding.UTF8.GetString(_history.Content(delete.HashBefore)!).ShouldBe("precious"); } [Fact] public async Task Deleting_a_folder_records_each_file() { await Run("a", new { action = "write", workspace = "shared", path = "old/1.md", content = "one" }); await Run("a", new { action = "write", workspace = "shared", path = "old/2.md", content = "two" }); await Run("a", new { action = "delete", workspace = "shared", path = "old" }); (await Journal()).Count(e => e.Action == FileAction.Delete).ShouldBe(2); } [Fact] public async Task A_copy_is_recorded_at_its_destination() { await Run("a", new { action = "write", workspace = "shared", path = "src.md", content = "x" }); await Run("a", new { action = "copy", workspace = "shared", path = "src.md", destinationPath = "dst.md" }); (await Journal()).Last().ShouldSatisfyAllConditions( e => e.Action.ShouldBe(FileAction.Copy), e => e.Path.ShouldBe("SharedWorkspace/dst.md")); } [Fact] public async Task The_personal_workspace_is_recorded_under_its_owner() { await Run("a", new { action = "write", path = "mine.md", content = "x" }); var entry = (await Journal()).Single(); entry.Workspace.ShouldBe("personal:a"); entry.Path.ShouldBe("Agent-a/Workspace/mine.md"); } [Fact] public async Task A_stock_data_point_records_the_file_and_the_index() { await Run("a", new { action = "stock_add", ticker = "ACME", category = "news", source = "test", title = "A title" }); var journal = await Journal(); journal.ShouldContain(e => e.Path.EndsWith("/_index.json") && e.Action == FileAction.Create); journal.ShouldContain(e => e.Path.Contains("/stocks/ACME/") && !e.Path.EndsWith("_index.json")); } }