Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Engine/OriginStampTests.cs
T
RichardandClaude Opus 5.5 f812dbd856 Verweis statt Nacherzaehlung: die Engine stempelt den Ursprung (#697, #677)
- Werkzeugkontext kennt Lauf und Aufgabe des Laufs (RunId, RunTaskId); die
  Delegationskette traegt den Lauf des Aufrufers weiter (RunId, CallerRunId)
- Aufgaben tragen origin_run / origin_task (Datei, DB, Taskboard "Arose from"),
  gestempelt, nie vom Agenten behauptet
- Kommentare nennen den Lauf, in dem sie geschrieben wurden
- Laufbeleg: CallerRunId; Laufansicht fuehrt zu "Delegiert von" und "Delegiert an"
- #677: task_update ersetzt den Rumpf nicht mehr, Abnahmekriterien aendert nur der
  Ersteller; Kommentare der Fremdinhalt-Stufe auf fremden Aufgaben kommen gerahmt an
- WebUI: Herkunft auf der Aufgabenkarte mit Link zum Lauf

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:27:47 +02:00

129 lines
5.4 KiB
C#

using System.Text.Json;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using ClawdDotNet.Core.Tools;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Engine;
/// <summary>
/// #697: No whispering down the lane. Whatever an agent hands on carries a reference to where
/// it came from — stamped by the engine, not claimed by the agent — so whoever decides can go
/// back to the original without asking anyone.
/// </summary>
public sealed class OriginStampTests
{
/// <summary>Remembers the context of each call — what the engine told the tool.</summary>
private sealed class ContextProbe : IAgentTool
{
public List<AgentToolContext> Seen { get; } = [];
public string Name => "TestTool";
public string Description => "probe";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""{ "type": "object" }""").RootElement.Clone();
public Task<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
Seen.Add(context);
return Task.FromResult(ToolResult.Ok("ok"));
}
}
[Fact]
public async Task A_tool_is_told_the_run_and_the_task_it_works_for()
{
var probe = new ContextProbe();
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit: audit).WithTool(probe);
var agent = fixture.AddAgent("ads", "TestTool");
fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("done");
await fixture.Engine.ChatAsync(agent, "work", "test-instance", default, taskId: "t-7");
var context = probe.Seen.ShouldHaveSingleItem();
context.RunTaskId.ShouldBe("t-7");
context.RunId.ShouldBe(audit.Receipts.Single().RunId);
context.Delegation!.RunId.ShouldBe(context.RunId, "the chain carries the run, for a delegation to name it");
}
[Fact]
public void Handing_on_carries_the_caller_s_run_to_the_next()
{
var chain = DelegationChain.Root("lead").WithRun("run-lead").Append("ads");
chain.CallerRunId.ShouldBe("run-lead");
chain.RunId.ShouldBeNull("the new agent's run does not exist yet");
chain.WithRun("run-ads").CallerRunId.ShouldBe("run-lead");
}
[Fact]
public async Task A_delegated_run_names_the_run_it_came_from()
{
var tool = new DelegatingTool(new Dictionary<string, string> { ["lead"] = "ads" });
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit: audit).WithTool(tool);
var lead = fixture.AddAgent("lead", "TestTool");
fixture.AddAgent("ads", "TestTool");
fixture.Client.RespondsContextually();
await fixture.Engine.ChatAsync(lead, "Please sort this out", "test-instance", default);
var leadRun = audit.Receipts.Single(r => r.AgentId == "lead");
var adsRun = audit.Receipts.Single(r => r.AgentId == "ads");
adsRun.CallerRunId.ShouldBe(leadRun.RunId);
leadRun.CallerRunId.ShouldBeNull();
(await audit.ListReceiptsCalledFromAsync(leadRun.RunId, default)).ShouldHaveSingleItem().RunId.ShouldBe(adsRun.RunId);
}
[Fact]
public async Task The_origin_of_a_task_survives_the_file_and_a_re_import()
{
var directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "origin-" + Guid.NewGuid().ToString("N")[..8]);
try
{
var repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(directory, "state.db")));
var board = new TaskboardService(repo, Path.Combine(directory, "tasks"));
await board.CreateAsync(new TaskItem { Id = "t-2", Title = "Below", OriginRunId = "run-1", OriginTaskId = "t-1" }, default);
var file = Directory.GetFiles(Path.Combine(directory, "tasks")).Single();
File.ReadAllText(file).ShouldContain("origin_run: run-1");
// A file edited by hand without the lines keeps what the database knows.
File.WriteAllText(file, File.ReadAllText(file).Replace("origin_run: run-1\n", "").Replace("origin_task: t-1\n", ""));
await board.ImportAllAsync(default);
var task = (await repo.GetAsync("t-2", default))!;
task.OriginRunId.ShouldBe("run-1");
task.OriginTaskId.ShouldBe("t-1");
}
finally
{
SqliteStorage.ReleasePooledConnections(directory);
try { Directory.Delete(directory, recursive: true); }
catch { /* cleaning up is secondary */ }
}
}
[Fact]
public async Task A_comment_names_the_run_it_was_written_in()
{
var directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "origin-" + Guid.NewGuid().ToString("N")[..8]);
try
{
var repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(directory, "state.db")));
var board = new TaskboardService(repo, Path.Combine(directory, "tasks"));
await board.CreateAsync(new TaskItem { Id = "t-1", Title = "Top" }, default);
var task = await board.AddCommentAsync("t-1", "ads", "Listing is live.", default, runId: "run-9");
task!.Body.ShouldContain(", run run-9):");
}
finally
{
SqliteStorage.ReleasePooledConnections(directory);
try { Directory.Delete(directory, recursive: true); }
catch { /* cleaning up is secondary */ }
}
}
}