Befunde aus dem Testbetrieb auf der Linux-VM behoben (#572)

- #578: Abgebrochene Laeufe (Schrittgrenze, Zeitueberschreitung, Abbruch,
  Ausnahme) tragen ihre bezahlten Token - vorher 0 in RunUsage, fuer das
  Budget unsichtbar.
- #580: WebFetch prueft die aufgeloeste Zieladresse beim Verbinden; ein
  erlaubter Name, der auf 127.0.0.1 aufloest, wird gesperrt.
- #575: Dateien entstehen unter Unix mit 0600 (AtomicFile, Protokolle,
  FileRW-Anhaengen, state.db), Protokollverzeichnisse mit 0700.
- #576: SIGTERM/SIGINT beenden auch mit Fenster geordnet.
- #593: OpenRouter-Statusdienst wird gestartet.
- #589: Auftragsnachricht nennt aktuelle Zeit, Zeitzone und Termin.
- #581: Unvollstaendiges Argument-JSON erreicht das Werkzeug nicht mehr;
  das Modell bekommt eine verstaendliche Rueckmeldung statt Parsertext.
- #591: Weboberflaeche liest die Felder, die API und Ereignisstrom liefern
  (Freigaben, Protokoll, Taskboard mit Termin, Werkzeug-Einstellungen,
  Tokennutzung); Ereignisstrom in camelCase.
- #592: Chat zeigt Nachrichten nicht mehr doppelt.
- #536 (teilweise): A2A-Antworten tragen Absender und Empfaenger, Parser
  wirft nicht mehr; Datums- und Volltextfilter in Agenten-Chats.
- deploy/publish.py: Packager-Pfad net10.0, Schalter --agent-file.
- Version 0.1.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-14 15:40:56 +02:00
co-authored by Claude Opus 5
parent e2567a466f
commit 6581fd0187
29 changed files with 1128 additions and 190 deletions
@@ -0,0 +1,147 @@
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Engine;
/// <summary>
/// Regressionstests fuer die Befunde aus dem Testbetrieb auf der Linux-VM (#572).
/// </summary>
public sealed class TestbetriebRegressionTests
{
// ═══════════════════════════════════════════════════════════
// #578 — Abgebrochene Laeufe tragen ihre bezahlten Token
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task RunAsync_an_der_Schrittgrenze_meldet_die_bis_dahin_bezahlten_Token()
{
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1)));
var agent = fixture.AddAgent("agent-grenze", "TestTool");
agent.LoopGuard.MaxSteps = 3;
// Jede Antwort ist ein Werkzeugaufruf mit 100 Prompt- und 20 Completion-Token.
fixture.Client.AlwaysRespondsWithToolCall("TestTool");
var result = await fixture.Engine.RunAsync(agent, "Arbeite", "test-instance", default);
result.Status.ShouldBe(AgentRunStatus.LoopLimitExceeded);
fixture.Client.CallCount.ShouldBe(3);
result.PromptTokens.ShouldBe(300, "drei bezahlte Aufrufe - vorher stand hier 0");
result.CompletionTokens.ShouldBe(60);
}
[Fact]
public async Task ChatAsync_an_der_Schrittgrenze_meldet_die_bis_dahin_bezahlten_Token()
{
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1)));
var agent = fixture.AddAgent("agent-chat-grenze", "TestTool");
agent.LoopGuard.MaxSteps = 2;
fixture.Client.AlwaysRespondsWithToolCall("TestTool");
var result = await fixture.Engine.ChatAsync(agent, "Arbeite", "test-instance", default);
result.Status.ShouldBe(AgentRunStatus.LoopLimitExceeded);
result.PromptTokens.ShouldBe(200);
result.CompletionTokens.ShouldBe(40);
}
[Fact]
public async Task RunAsync_mit_Fehler_des_Anbieters_meldet_die_bis_dahin_bezahlten_Token()
{
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1)));
var agent = fixture.AddAgent("agent-fehler", "TestTool");
fixture.Client
.RespondsWithToolCall("TestTool")
.Throws(new HttpRequestException("502 vom Anbieter"));
var result = await fixture.Engine.RunAsync(agent, "Arbeite", "test-instance", default);
result.Status.ShouldBe(AgentRunStatus.Failed);
result.PromptTokens.ShouldBe(100);
}
// ═══════════════════════════════════════════════════════════
// #581 — Unvollstaendiges Argument-JSON
// ═══════════════════════════════════════════════════════════
[Theory]
[InlineData("{\"action\": \"write\", \"workspace\": \"personal\", \"path\": \"gross.txt\"", false)]
[InlineData("{\"action\": \"send_message\", \"targetAgentId\": \"netzpruefer\"", false)]
[InlineData("{\"action\": \"list\"}", true)]
public void IstVollstaendigesJson_erkennt_abgeschnittene_Argumente(string arguments, bool erwartet)
=> AgentEngine.IstVollstaendigesJson(arguments).ShouldBe(erwartet);
[Fact]
public async Task Abgeschnittene_Argumente_erreichen_das_Werkzeug_nicht_und_das_Modell_erfaehrt_warum()
{
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1)));
var agent = fixture.AddAgent("agent-abgeschnitten", "TestTool");
// Genau die Form aus dem Pruefstand: Das JSON bricht vor dem grossen Wert ab.
fixture.Client
.RespondsWithToolCall("TestTool", "{\"action\": \"write\", \"path\": \"gross.txt\"")
.RespondsWithText("verstanden");
await fixture.Engine.RunAsync(agent, "Schreibe viel", "test-instance", default);
var antwortAnsModell = fixture.Client.ReceivedRequests[1].Messages.Last();
antwortAnsModell.Role.ShouldBe("tool");
antwortAnsModell.Content.ShouldNotBeNull();
antwortAnsModell.Content.ShouldContain("kein vollstaendiges JSON");
antwortAnsModell.Content.ShouldContain("mehrere kleinere Aufrufe");
antwortAnsModell.Content.ShouldNotContain("Expected depth");
}
// ═══════════════════════════════════════════════════════════
// #589 — Zeit und Termin in der Auftragsnachricht
// ═══════════════════════════════════════════════════════════
[Fact]
public void Zeitangabe_nennt_Ortszeit_der_Aufgabe_UTC_und_Termin()
{
var task = new TaskItem
{
Id = "t-1",
Title = "Wetter",
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "5 * * * *", TimeZone = "Europe/Berlin" }
};
var text = EngineTaskDispatcher.Zeitangabe(task, new DateTimeOffset(2026, 9, 14, 13, 5, 0, TimeSpan.Zero));
text.ShouldContain("14.09.2026 15:05 Uhr");
text.ShouldContain("Europe/Berlin");
text.ShouldContain("2026-09-14T13:05Z");
text.ShouldContain("cron \"5 * * * *\"");
}
[Fact]
public void Auftragsnachricht_enthaelt_die_Zeitangabe()
{
var fixture = new EngineFixture();
var dispatcher = new EngineTaskDispatcher(
fixture.Engine, () => fixture.Agents, "test-instance", fixture.Registry, fixture.StateStore,
NullLoggerFactory.Instance, new FesteZeit(new DateTimeOffset(2026, 1, 5, 7, 15, 0, TimeSpan.Zero)));
var nachricht = dispatcher.BuildMessage(new TaskItem
{
Id = "t-2",
Title = "Morgenbericht",
Body = "Schreibe den Bericht.",
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "15 7 * * *", TimeZone = "Europe/Berlin" }
});
nachricht.ShouldStartWith("[Aufgabe t-2] Morgenbericht");
nachricht.ShouldContain("Aktuelle Zeit: 05.01.2026 08:15 Uhr (Europe/Berlin, UTC+01:00)");
nachricht.ShouldContain("Schreibe den Bericht.");
}
private sealed class FesteZeit(DateTimeOffset jetzt) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => jetzt;
}
}
@@ -0,0 +1,74 @@
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Storage;
/// <summary>
/// #575: Auf der Test-VM (umask 0002) ging InstanceSettings.json beim ersten Speichern von
/// 0600 auf 0664, Protokolle entstanden mit 0664. Unter Windows nicht aussagekraeftig.
/// </summary>
public sealed class OwnerOnlyFileTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "clawd-owner-" + Guid.NewGuid().ToString("N")[..8]);
public OwnerOnlyFileTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { /* Aufraeumen ist best effort */ }
}
private const UnixFileMode NurBesitzer = UnixFileMode.UserRead | UnixFileMode.UserWrite;
[LinuxFact]
[System.Runtime.Versioning.SupportedOSPlatform("linux")]
public void AtomicFile_ersetzt_eine_offene_Datei_durch_eine_nur_fuer_den_Besitzer()
{
var pfad = Path.Combine(_dir, "InstanceSettings.json");
File.WriteAllText(pfad, "{}");
File.SetUnixFileMode(pfad, NurBesitzer | UnixFileMode.GroupRead | UnixFileMode.OtherRead);
AtomicFile.WriteAllText(pfad, "{\"a\":1}");
File.GetUnixFileMode(pfad).ShouldBe(NurBesitzer);
}
[LinuxFact]
[System.Runtime.Versioning.SupportedOSPlatform("linux")]
public void AtomicFile_legt_neue_Dateien_nur_fuer_den_Besitzer_an()
{
var pfad = Path.Combine(_dir, "neu.json");
AtomicFile.WriteAllText(pfad, "{}");
File.GetUnixFileMode(pfad).ShouldBe(NurBesitzer);
}
[LinuxFact]
[System.Runtime.Versioning.SupportedOSPlatform("linux")]
public void OpenAppend_legt_neue_Dateien_nur_fuer_den_Besitzer_an()
{
var pfad = Path.Combine(_dir, "protokoll.log");
using (var stream = OwnerOnlyFile.OpenAppend(pfad))
stream.WriteByte((byte)'x');
File.GetUnixFileMode(pfad).ShouldBe(NurBesitzer);
}
[Fact]
public void OpenAppend_haengt_an_statt_zu_ueberschreiben()
{
var pfad = Path.Combine(_dir, "anhang.txt");
foreach (var teil in new[] { "eins", "zwei" })
{
using var stream = OwnerOnlyFile.OpenAppend(pfad);
using var writer = new StreamWriter(stream);
writer.Write(teil);
}
File.ReadAllText(pfad).ShouldBe("einszwei");
}
}