Bugtracker-Durchgang: 13 Bugs behoben, Build ohne Warnungen

Alle Behebungen auf der Linux-Test-VM gegengeprueft (Ubuntu, .NET 10.0.111):
Build 0 Warnungen / 0 Fehler, 637 Tests gruen, 1 uebersprungen (WindowsFact).

Sicherheit und Zielplattform
  #373 PathBoundary.Canonicalize fiel offen aus. File.ResolveLinkTarget wirft
       fuer einen fehlenden Abschnitt FileNotFoundException; der catch(IOException)
       verliess daraufhin die GANZE Aufloesung mit dem unaufgeloesten Pfad - und
       der besteht jede Praefixpruefung. Der Symlink-Schutz war damit bei jedem
       Schreibvorgang wirkungslos. Jetzt ueber FileSystemInfo.LinkTarget, und
       TryCanonicalize meldet "nicht bestimmbar", statt fail-open durchzureichen.
  #374 WorkspacePath weist den Backslash rundheraus ab, wie den Doppelpunkt.
       Eine Schreibweise statt einer Sonderregel je Windows-Form.
  #375 Log-Verzeichnis: leer heisst "Anwendungseinstellung"; ein relativer Wert
       zaehlt gegen das Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis
       des Prozesses. Ein systemd-Dienst landet nicht mehr auf /Logs.

Agentenkommunikation
  #206 Delegationskette gegen A->B->A (Test A13). Zyklusfrei per Bauart, dazu
       MaxDelegationDepth gegen die Staffel, die niemand beauftragt hat.
  #461 Zwei unabhaengige Ketten ueber Kreuz - beim Beheben von #206 gefunden und
       reproduziert. Ein delegierter Lauf wartet nur noch begrenzt auf ein fremdes
       Gate; ein Chat vom Menschen reiht sich weiter unbegrenzt ein.
  #234 Instanz-Id wird durchgereicht statt aus einem nur beilaeufig gesetzten Feld
       gelesen.

Betrieb und Oberflaeche
  #376 Log-Ansicht chronologisch statt gruppiert je Moduldatei, mit Stacktraces
       an ihrer Meldung - auch ueber Taktgrenzen hinweg.
  #377 Info-Tabelle: feste Spaltenbreiten, damit ein langer Aenderungstext den
       anderen Spalten nicht den Platz nimmt.
  #378 Sicherung: Mindestabstand zwischen Ueberschrift und Schaltflaechenleiste.
  #379 Hellmodus: drei von vier gedaempften Textpinseln lagen unter 4,5:1. Die
       helle Palette ist als Ganzes verschoben, damit die Staffelung sichtbar
       bleibt statt auf einen Wert zusammenzufallen.

Hygiene
  #233 stock_add: der Indexstand wird innerhalb der Sperre festgehalten.
  #235 Zeitumstellung: eine nicht existierende Ortszeit wird auf den ersten
       Zeitpunkt nach der Luecke verschoben statt uebersprungen (R4, R5).
  #236 45 Nullable-Warnungen und eine CA1416 - nicht unterdrueckt, sondern die
       Stellen so umgebaut, dass die Zusage nachvollziehbar wird. Mail meldet
       jetzt, WELCHER Zugangsschluessel fehlt, statt an einer
       KeyNotFoundException zu scheitern.
  #203 Kompaktiert wird vor dem API-Aufruf statt danach; die gemessenen
       Prompt-Groessen kalibrieren die Schaetzung je Modell.

Neu: tests/ClawdDotNet.App.Tests - fuer #375 und #376 gab es keinen Ort fuer
Tests. Dazu Regressionstests fuer jede Behebung; jeder wurde gegen den alten
Stand geprueft, damit belegt ist, dass er den Fehler faengt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-03 19:04:48 +02:00
co-authored by Claude Opus 5
parent 740649789e
commit cffd6f4b77
44 changed files with 1942 additions and 228 deletions
@@ -81,49 +81,45 @@ public sealed class DirectApiTool : IAgentTool
{
var apiKey = GetApiKey(providers, "twelvedata");
var url = $"https://api.twelvedata.com/quote?symbol={symbol}&apikey={apiKey}";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
if (json.Value.TryGetProperty("code", out var code) && code.GetInt32() != 200)
return ToolResult.Fail(json.Value.GetProperty("message").GetString() ?? "API Fehler");
if (json.TryGetProperty("code", out var code) && code.GetInt32() != 200)
return ToolResult.Fail(json.GetProperty("message").GetString() ?? "API Fehler");
DateTime? dataAsOf = null;
if (json.Value.TryGetProperty("timestamp", out var ts))
if (json.TryGetProperty("timestamp", out var ts))
dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
return CreateSuccessResult(fetchedAt, dataAsOf, url, json.Value);
return CreateSuccessResult(fetchedAt, dataAsOf, url, json);
}
else if (provider == "alphavantage")
{
var apiKey = GetApiKey(providers, "alphavantage");
var url = $"https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol={symbol}&apikey={apiKey}";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
if (json.Value.TryGetProperty("Note", out var note))
if (json.TryGetProperty("Note", out var note))
return ToolResult.Fail($"Alpha Vantage Rate Limit: {note.GetString()}");
if (json.Value.TryGetProperty("Error Message", out var err))
if (json.TryGetProperty("Error Message", out var err))
return ToolResult.Fail($"Alpha Vantage Fehler: {err.GetString()}");
return CreateSuccessResult(fetchedAt, fetchedAt, url, json.Value);
return CreateSuccessResult(fetchedAt, fetchedAt, url, json);
}
else if (provider == "massive")
{
var apiKey = GetApiKey(providers, "massive");
var url = $"https://api.massive.com/v3/snapshot?ticker={symbol}";
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
return CreateSuccessResult(fetchedAt, fetchedAt, url, json.Value);
return CreateSuccessResult(fetchedAt, fetchedAt, url, json);
}
else if (provider == "yahoo")
{
var url = $"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval=1m&range=1d";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
if (!json.Value.TryGetProperty("chart", out var chartProp) ||
if (!json.TryGetProperty("chart", out var chartProp) ||
!chartProp.TryGetProperty("result", out var resultArr) ||
resultArr.GetArrayLength() == 0)
return ToolResult.Fail($"Yahoo Finance: Keine Daten für '{symbol}' erhalten.");
@@ -153,13 +149,12 @@ public sealed class DirectApiTool : IAgentTool
var apiKey = GetApiKey(providers, "twelvedata");
var url = $"https://api.twelvedata.com/time_series?symbol={symbol}&interval={interval}&outputsize={outputSize}&apikey={apiKey}";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
if (json.Value.TryGetProperty("status", out var status) && status.GetString() != "ok")
return ToolResult.Fail(json.Value.GetProperty("message").GetString() ?? "API Fehler");
if (json.TryGetProperty("status", out var status) && status.GetString() != "ok")
return ToolResult.Fail(json.GetProperty("message").GetString() ?? "API Fehler");
return CreateSuccessResult(fetchedAt, null, url, json.Value);
return CreateSuccessResult(fetchedAt, null, url, json);
}
else if (provider == "alphavantage")
{
@@ -177,15 +172,14 @@ public sealed class DirectApiTool : IAgentTool
var urlBase = $"https://www.alphavantage.co/query?function={function_}&symbol={symbol}&apikey={apiKey}&outputsize=compact";
if (!string.IsNullOrEmpty(avInterval)) urlBase += $"&interval={avInterval}";
var (json, error) = await SafeGetJsonAsync(http, urlBase, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, urlBase, ct)).TryGet(out var json, out var error)) return error;
if (json.Value.TryGetProperty("Note", out var note))
if (json.TryGetProperty("Note", out var note))
return ToolResult.Fail($"Alpha Vantage Rate Limit: {note.GetString()}");
if (json.Value.TryGetProperty("Error Message", out var err))
if (json.TryGetProperty("Error Message", out var err))
return ToolResult.Fail($"Alpha Vantage Fehler: {err.GetString()}");
return CreateSuccessResult(fetchedAt, null, urlBase, json.Value);
return CreateSuccessResult(fetchedAt, null, urlBase, json);
}
else if (provider == "massive")
{
@@ -204,10 +198,9 @@ public sealed class DirectApiTool : IAgentTool
var url = $"https://api.massive.com/v2/aggs/ticker/{symbol}/range/{multiplier}/{timespan}/{from}/{to}?limit={outputSize}";
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
return CreateSuccessResult(fetchedAt, null, url, json.Value);
return CreateSuccessResult(fetchedAt, null, url, json);
}
else if (provider == "yahoo")
{
@@ -219,10 +212,9 @@ public sealed class DirectApiTool : IAgentTool
var range = interval is "1min" or "5min" or "15min" ? "1d" : $"{outputSize}d";
var url = $"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval={yahooInterval}&range={range}";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
if (!json.Value.TryGetProperty("chart", out var chartProp) ||
if (!json.TryGetProperty("chart", out var chartProp) ||
!chartProp.TryGetProperty("result", out var resultArr) ||
resultArr.GetArrayLength() == 0)
return ToolResult.Fail($"Yahoo Finance: Keine Historien-Daten für '{symbol}' erhalten.");
@@ -240,10 +232,9 @@ public sealed class DirectApiTool : IAgentTool
var fetchedAt = DateTime.UtcNow;
var url = $"https://api.coingecko.com/api/v3/simple/price?ids={symbol.ToLowerInvariant()}&vs_currencies=usd&include_last_updated_at=true";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
if (!json.Value.TryGetProperty(symbol.ToLowerInvariant(), out var data))
if (!json.TryGetProperty(symbol.ToLowerInvariant(), out var data))
return ToolResult.Fail($"Symbol '{symbol}' nicht gefunden.");
DateTime? dataAsOf = null;
@@ -262,14 +253,13 @@ public sealed class DirectApiTool : IAgentTool
var fetchedAt = DateTime.UtcNow;
var url = $"https://api.twelvedata.com/exchange_rate?symbol={symbol}&apikey={apiKey}";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
DateTime? dataAsOf = null;
if (json.Value.TryGetProperty("timestamp", out var ts))
if (json.TryGetProperty("timestamp", out var ts))
dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
return CreateSuccessResult(fetchedAt, dataAsOf, url, json.Value);
return CreateSuccessResult(fetchedAt, dataAsOf, url, json);
}
private async Task<ToolResult> SearchSymbolAsync(string symbol, string provider, JsonElement? providers, AgentToolContext context, CancellationToken ct)
@@ -281,16 +271,54 @@ public sealed class DirectApiTool : IAgentTool
var fetchedAt = DateTime.UtcNow;
var url = $"https://api.twelvedata.com/symbol_search?symbol={symbol}"; // search often doesn't need key or uses same
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
return CreateSuccessResult(fetchedAt, null, url, json.Value);
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
return CreateSuccessResult(fetchedAt, null, url, json);
}
/// <summary>
/// Das Ergebnis eines API-Aufrufs: <b>entweder</b> Daten <b>oder</b> eine Absage —
/// nie beides, nie keines von beiden.
///
/// <para>Vorher war das ein Tupel <c>(JsonElement? json, ToolResult? error)</c>, und
/// jede der elf Aufrufstellen las danach <c>json.Value</c>. Dass der Wert nach
/// <c>if (error != null) return error;</c> immer da ist, wusste nur der Mensch —
/// der Compiler wies mit elf CS8629 darauf hin, dass er es nicht nachvollziehen
/// kann. Elf Warnungen, die man wegzusehen lernt, verdecken die zwölfte, die
/// stimmt.</para>
///
/// <para><see cref="TryGet"/> sagt dasselbe so, dass es nachvollziehbar ist: Wer
/// <c>true</c> bekommt, hat die Daten; wer <c>false</c> bekommt, hat die Absage,
/// die er zurückgeben soll.</para>
/// </summary>
private readonly struct JsonFetch
{
private readonly JsonElement _json;
private readonly ToolResult? _error;
private JsonFetch(JsonElement json, ToolResult? error)
{
_json = json;
_error = error;
}
public static JsonFetch Ok(JsonElement json) => new(json, null);
public static JsonFetch Fail(ToolResult error) => new(default, error);
public bool TryGet(out JsonElement json, out ToolResult error)
{
json = _json;
// Der Ersatz greift nie: Fail verlangt eine Absage, Ok setzt keine. Er steht
// hier, damit die Zusage ohne Ausrufezeichen auskommt.
error = _error ?? ToolResult.Fail("Unbekannter Fehler beim API-Aufruf.");
return _error is null;
}
}
/// <summary>
/// Sichere JSON-Antwort von einer API abrufen.
/// Prüft HTTP-Status und ob die Antwort tatsächlich JSON ist, bevor geparst wird.
/// </summary>
private static async Task<(JsonElement? json, ToolResult? error)> SafeGetJsonAsync(
private static async Task<JsonFetch> SafeGetJsonAsync(
HttpClient http, string url, CancellationToken ct)
{
// Auch Fehlermeldungen gehen an das Modell — der Schlüssel darf darin nicht auftauchen.
@@ -303,7 +331,7 @@ public sealed class DirectApiTool : IAgentTool
}
catch (HttpRequestException ex)
{
return (null, ToolResult.Fail($"API nicht erreichbar: {safeUrl} → {ex.Message}"));
return JsonFetch.Fail(ToolResult.Fail($"API nicht erreichbar: {safeUrl} → {ex.Message}"));
}
var body = await response.Content.ReadAsStringAsync(ct);
@@ -311,7 +339,7 @@ public sealed class DirectApiTool : IAgentTool
if (!response.IsSuccessStatusCode)
{
var preview = body.Length > 300 ? body[..300] + "…" : body;
return (null, ToolResult.Fail(
return JsonFetch.Fail(ToolResult.Fail(
$"API Fehler: HTTP {(int)response.StatusCode} {response.ReasonPhrase} von {safeUrl}\nAntwort: {preview}"));
}
@@ -320,19 +348,19 @@ public sealed class DirectApiTool : IAgentTool
if (trimmed.Length == 0 || (trimmed[0] != '{' && trimmed[0] != '['))
{
var preview = body.Length > 300 ? body[..300] + "…" : body;
return (null, ToolResult.Fail(
return JsonFetch.Fail(ToolResult.Fail(
$"API hat kein JSON zurückgegeben ({safeUrl}). Antwort: {preview}"));
}
try
{
var json = JsonDocument.Parse(body).RootElement.Clone();
return (json, null);
return JsonFetch.Ok(json);
}
catch (JsonException ex)
{
var preview = body.Length > 300 ? body[..300] + "…" : body;
return (null, ToolResult.Fail(
return JsonFetch.Fail(ToolResult.Fail(
$"Ungültiges JSON von {safeUrl}: {ex.Message}\nAntwort: {preview}"));
}
}