Files
PolyTraderSharp/tests/PolyTrader.Tests/SupervisorAgentTests.cs
T
RichardandClaude Opus 4.8 b7b2a141e3 Supervisor S-3/S-4: MCP-Light-Server, Profile, gespeicherte Berichte, Designer-UI
S-4 MCP-Light (Daten-Tuer fuer externe KI-Clients, KEIN Modell-Zugang - Modelle laufen
weiter ueber OpenRouter):
- McpJsonRpc (pur): JSON-RPC 2.0 fuer initialize/ping/tools/list/tools/call ueber die
  read-only Tool-Registry; Notifications/Fehlerfaelle spezifikationskonform.
- McpLightServer (HostedService): lokaler Streamable-HTTP-Endpoint. OPT-IN via
  POLYTRADER_MCP_PORT, bindet NUR 127.0.0.1. Claude Code:
  claude mcp add --transport http polytrader http://127.0.0.1:PORT/mcp
- End-to-End-Test ueber echtes HTTP (initialize, tools/call, Notification=202, GET=405).

S-3 Profile + Berichte:
- SupervisorProfiles: Allgemein/Technik/CopyTrading/ResolutionFarming als System-Prompt-
  Zusatz + Tool-Subset ueber EINER Agent-Infrastruktur (Technik z.B. ohne Strategie-Tools).
  Agent filtert Tools je Profil.
- sup_reports (SupervisorDbContext, Migration generiert UND angewendet): jede Analyse wird
  mit Profil/Modell/Frage/Antwort/Tool-Aufrufen/Token gespeichert -> Supervisor auditierbar.

UI (Richards Vorgabe: designerfaehig):
- SupervisorMainForm auf partial + .Designer.cs umgestellt - alle Controls im Designer
  (3 Tabs: Analyse mit Profil-Combo+Modellfeld, Dossiers, Berichte mit Split/Grid/Detail).

Tests: +16 (MCP-JSON-RPC 6, MCP-HTTP-E2E 1, Profile 2, bestehende erweitert). Build 0 Fehler,
360 Tests gruen, --smoke-ui alle 5 Views gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 11:00:28 +02:00

188 lines
8.2 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using PolyTrader.Modules.Supervisor.Agent;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>
/// Sicherheitsnetz für den S-2-Agenten: Tool-Registry (Ausführung, Fehler, Unbekanntes),
/// Function-Calling-Loop (Tool-Ergebnisse fließen zurück, Iterationsgrenze) und die
/// OpenRouter-Serialisierung (Request-Body pur, Response-Parsing).
/// </summary>
public class SupervisorAgentTests
{
// ----- Registry -----
private static SupervisorToolRegistry RegistryWithEcho()
{
var reg = new SupervisorToolRegistry();
reg.Register(new SupervisorTool("echo", "Echo",
"""{"type":"object","properties":{"text":{"type":"string"}}}""",
args => "ECHO:" + (SupervisorToolRegistry.GetString(args, "text") ?? "")));
reg.Register(new SupervisorTool("boom", "wirft",
"""{"type":"object","properties":{}}""",
_ => throw new InvalidOperationException("kaputt")));
return reg;
}
[Fact]
public void Registry_executes_tool_with_args()
{
Assert.Equal("ECHO:hallo", RegistryWithEcho().Execute("echo", """{"text":"hallo"}"""));
}
[Fact]
public void Registry_unknown_tool_and_broken_args_return_error_strings()
{
var reg = RegistryWithEcho();
Assert.StartsWith("FEHLER: Unbekanntes Tool", reg.Execute("gibtsnicht", "{}"));
Assert.StartsWith("FEHLER: Ungültige Tool-Argumente", reg.Execute("echo", "{kein json"));
Assert.StartsWith("FEHLER bei Tool 'boom'", reg.Execute("boom", "{}")); // Exception -> Text, wirft nie
}
// ----- Agent-Loop -----
private sealed class ScriptedChatClient : IChatCompletionClient
{
private readonly Queue<ChatResponse> _script;
public List<IReadOnlyList<ChatMessage>> Requests { get; } = new();
public List<IReadOnlyList<SupervisorTool>> OfferedTools { get; } = new();
public ScriptedChatClient(params ChatResponse[] script) => _script = new Queue<ChatResponse>(script);
public Task<ChatResponse> CompleteAsync(string model, IReadOnlyList<ChatMessage> messages,
IReadOnlyList<SupervisorTool> tools, CancellationToken ct)
{
Requests.Add(new List<ChatMessage>(messages));
OfferedTools.Add(new List<SupervisorTool>(tools));
return Task.FromResult(_script.Count > 0 ? _script.Dequeue() : new ChatResponse { Content = "leer" });
}
}
[Fact]
public async Task Agent_executes_tool_calls_and_feeds_results_back()
{
var chat = new ScriptedChatClient(
new ChatResponse { ToolCalls = { new ToolCall("c1", "echo", """{"text":"daten"}""") } },
new ChatResponse { Content = "Fertige Analyse." });
var agent = new SupervisorAgent(chat, RegistryWithEcho());
var result = await agent.AskAsync("Warum X?");
Assert.Equal("Fertige Analyse.", result.Answer);
Assert.Single(result.ToolInvocations);
Assert.Equal(("echo", """{"text":"daten"}""", "ECHO:daten"), result.ToolInvocations[0]);
// Zweiter Request enthält Assistant-ToolCall + Tool-Ergebnis.
var second = chat.Requests[1];
Assert.Contains(second, m => m.Role == "assistant" && m.ToolCalls is { Count: 1 });
Assert.Contains(second, m => m.Role == "tool" && m.ToolCallId == "c1" && m.Content == "ECHO:daten");
// System-Prompt enthält den Architektur-Kontext.
Assert.Contains("ARCHITEKTUR-KONTEXT", second[0].Content);
}
[Fact]
public async Task Agent_stops_at_max_iterations()
{
// Modell fordert ENDLOS Tools an -> Agent bricht kontrolliert ab.
var endless = Enumerable.Range(0, SupervisorAgent.MaxIterations + 2)
.Select(i => new ChatResponse { ToolCalls = { new ToolCall($"c{i}", "echo", "{}") } })
.ToArray();
var agent = new SupervisorAgent(new ScriptedChatClient(endless), RegistryWithEcho());
var result = await agent.AskAsync("loop");
Assert.Contains("maximale Tool-Iterationen", result.Answer);
Assert.Equal(SupervisorAgent.MaxIterations, result.ToolInvocations.Count);
}
// ----- OpenRouter-Serialisierung (pur) -----
[Fact]
public void BuildRequestBody_produces_openai_compatible_json()
{
var messages = new List<ChatMessage>
{
ChatMessage.System("sys"),
ChatMessage.User("frage"),
ChatMessage.Assistant(null, new List<ToolCall> { new("c1", "echo", """{"text":"x"}""") }),
ChatMessage.ToolResult("c1", "ergebnis")
};
var tools = new List<SupervisorTool>
{
new("echo", "Echo-Tool", """{"type":"object","properties":{"text":{"type":"string"}}}""", _ => "")
};
string body = OpenRouterClient.BuildRequestBody("openrouter/auto", messages, tools);
using var doc = JsonDocument.Parse(body); // valides JSON
var root = doc.RootElement;
Assert.Equal("openrouter/auto", root.GetProperty("model").GetString());
var msgs = root.GetProperty("messages");
Assert.Equal(4, msgs.GetArrayLength());
Assert.Equal("function", msgs[2].GetProperty("tool_calls")[0].GetProperty("type").GetString());
Assert.Equal("echo", msgs[2].GetProperty("tool_calls")[0].GetProperty("function").GetProperty("name").GetString());
Assert.Equal("c1", msgs[3].GetProperty("tool_call_id").GetString());
Assert.Equal("echo", root.GetProperty("tools")[0].GetProperty("function").GetProperty("name").GetString());
}
[Fact]
public void ParseResponse_reads_content_toolcalls_and_usage()
{
const string json = """
{"choices":[{"message":{"content":null,"tool_calls":[
{"id":"call_1","type":"function","function":{"name":"query_trades","arguments":"{\"limit\":5}"}}]}}],
"usage":{"prompt_tokens":120,"completion_tokens":30}}
""";
var r = OpenRouterClient.ParseResponse(json);
Assert.Null(r.Content);
Assert.Single(r.ToolCalls);
Assert.Equal("query_trades", r.ToolCalls[0].Name);
Assert.Equal("""{"limit":5}""", r.ToolCalls[0].ArgumentsJson);
Assert.Equal(120, r.PromptTokens);
Assert.Equal(30, r.CompletionTokens);
}
[Fact]
public void ArchitectureContext_is_embedded_and_loads()
{
string ctx = ArchitectureContext.Load();
Assert.Contains("PolyTrader", ctx);
Assert.Contains("read-only", ctx);
}
// ----- Profile (S-3) -----
[Fact]
public async Task Technik_profile_filters_tools_and_extends_prompt()
{
var reg = RegistryWithEcho();
reg.Register(new SupervisorTool("read_logs", "Logs", """{"type":"object","properties":{}}""", _ => "logs"));
var chat = new ScriptedChatClient(new ChatResponse { Content = "ok" });
var agent = new SupervisorAgent(chat, reg);
await agent.AskAsync("check", profile: SupervisorProfiles.Technik);
// Prompt trägt den Profil-Fokus; dem Modell wurde NUR das Technik-Subset angeboten
// (Registry hat echo/boom/read_logs → im Technik-Filter ist davon nur read_logs).
Assert.Contains("FOKUS TECHNIK-SUPERVISOR", chat.Requests[0][0].Content);
Assert.Single(chat.OfferedTools[0]);
Assert.Equal("read_logs", chat.OfferedTools[0][0].Name);
}
[Fact]
public void Profiles_lookup_is_case_insensitive_with_fallback()
{
Assert.Equal("Technik", SupervisorProfiles.ByName("technik").Name);
Assert.Equal("Allgemein", SupervisorProfiles.ByName("gibtsnicht").Name);
Assert.Equal(4, SupervisorProfiles.All.Count);
}
}
}