fix: ask_user_choice Short-Poll, Freitext-Option, Stop-Kontext-Fix

- Long-Poll durch Short-Poll ersetzt (MCP-Server pollt jede Sekunde statt einer blockierenden HTTP-Anfrage)
- Expliziter Lambda-Rückgabetyp Task<IResult> für ASP.NET Minimal API Endpoints (behebt 200 empty body Bug)
- Freitext-Option bei Choice-Buttons ("Eigene Antwort…") für freie User-Eingabe
- Stop während ask_user_choice markiert Chain als awaiting_user_choice statt stopped (Kontext bleibt erhalten)
- ask_user_choice System-Prompt-Instruktion breiter formuliert (Quiz, Multiple-Choice etc.)
- Text wird jetzt immer in DB gespeichert, auch bei ask_user_choice
- Key-Check Model auf claude-haiku-4-5 geändert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.6 committed 2026-03-18 11:32:25 +01:00
1 parent 3e0a69e7a4
commit 92ecaf9dac
8 files changed
+184 -66

No files matched your search

+18 -40
View File
@@ -24,16 +24,19 @@ public static class InternalEndpoint
public static string InternalToken { get; } = Guid.NewGuid().ToString("N");
/// <summary>
/// Pending user choices – Key ist sessionId, Value wird completed wenn der User klickt.
/// Beantwortete User-Choices – Key ist sessionId, Value ist die gewählte Option.
/// MCP-Server pollt dieses Dictionary (Short-Poll statt Long-Poll).
/// </summary>
public static readonly ConcurrentDictionary<string, TaskCompletionSource<string>> PendingChoices = new();
public static readonly ConcurrentDictionary<string, string> ChoiceAnswers = new();
public static void MapInternalEndpoints(this IEndpointRouteBuilder app)
{
app.MapPost("/api/internal/remote-execute", HandleRemoteExecute);
app.MapPost("/api/internal/ask-user-choice", HandleAskUserChoice);
// WICHTIG: Expliziter Rückgabetyp Task<IResult> nötig! Ohne ihn interpretiert ASP.NET den Lambda als
// RequestDelegate (Func<HttpContext,Task>) → IResult-Rückgabe wird ignoriert → 200 empty body.
app.MapPost("/api/internal/ask-user-choice", async Task<IResult> (HttpContext ctx) => await HandleAskUserChoice(ctx));
// choice-respond ist NICHT unter /api/internal/ weil es vom Frontend aufgerufen wird (normale Auth)
app.MapPost("/api/chat/choice-respond", HandleChoiceRespond);
app.MapPost("/api/chat/choice-respond", async Task<IResult> (HttpContext ctx) => await HandleChoiceRespond(ctx));
app.MapPost("/api/internal/memory-read", HandleMemoryRead);
app.MapPost("/api/internal/memory-write", HandleMemoryWrite);
app.MapPost("/api/internal/memory-list", HandleMemoryList);
@@ -85,8 +88,8 @@ public static class InternalEndpoint
}
/// <summary>
/// MCP-Server ruft diesen Endpoint auf und blockiert bis der User eine Auswahl trifft.
/// Der TCS wird vorher von AnthropicService erstellt (beim tool_use Event).
/// MCP-Server pollt diesen Endpoint bis der User eine Auswahl getroffen hat.
/// Gibt 200 mit { choice } zurück wenn beantwortet, 404 wenn noch ausstehend.
/// </summary>
private static async Task<IResult> HandleAskUserChoice(HttpContext ctx)
{
@@ -105,34 +108,12 @@ public static class InternalEndpoint
if (string.IsNullOrEmpty(sessionId))
return Results.BadRequest(new { error = "sessionId erforderlich" });
// Warte auf den TCS der von AnthropicService erstellt wurde
if (!PendingChoices.TryGetValue(sessionId, out var tcs))
{
// TCS noch nicht erstellt (Race Condition) – kurz warten und nochmal probieren
for (int i = 0; i < 10; i++)
{
await Task.Delay(500, ctx.RequestAborted);
if (PendingChoices.TryGetValue(sessionId, out tcs)) break;
}
if (tcs == null)
return Results.Json(new { error = "Keine ausstehende Auswahl für diese Session" }, statusCode: 404);
}
// Blockieren bis User antwortet (max 5 Minuten)
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted);
cts.CancelAfter(TimeSpan.FromMinutes(5));
try
{
var choice = await tcs.Task.WaitAsync(cts.Token);
PendingChoices.TryRemove(sessionId, out _);
// Prüfen ob bereits eine Antwort vorliegt (Short-Poll)
if (ChoiceAnswers.TryRemove(sessionId, out var choice))
return Results.Ok(new { choice });
}
catch (OperationCanceledException)
{
PendingChoices.TryRemove(sessionId, out _);
return Results.Json(new { error = "Timeout – keine Auswahl getroffen" }, statusCode: 408);
}
// Noch keine Antwort – MCP-Server soll erneut pollen
return Results.Json(new { status = "pending" }, statusCode: 202);
}
catch (Exception ex)
{
@@ -142,6 +123,7 @@ public static class InternalEndpoint
/// <summary>
/// Frontend ruft diesen Endpoint auf wenn der User einen Choice-Button klickt (CLI-Modus).
/// Speichert die Antwort in ChoiceAnswers – MCP-Server holt sie beim nächsten Poll ab.
/// </summary>
private static async Task<IResult> HandleChoiceRespond(HttpContext ctx)
{
@@ -162,13 +144,9 @@ public static class InternalEndpoint
if (string.IsNullOrEmpty(choice))
return Results.BadRequest(new { error = "choice erforderlich" });
if (PendingChoices.TryGetValue(sessionId, out var tcs))
{
tcs.TrySetResult(choice);
return Results.Ok(new { success = true });
}
return Results.NotFound(new { error = "Keine ausstehende Auswahl für diese Session" });
// Antwort speichern – MCP-Server holt sie beim nächsten Poll ab
ChoiceAnswers[sessionId] = choice;
return Results.Ok(new { success = true });
}
catch (Exception ex)
{