Stream panel member responses live with a parallel SSE write-lock

Panel members now stream token-by-token and show tool uses in real time
(like the session agent) instead of delivering text only at completion.
The key constraint is byte-level safety: N members flushing SSE frames
concurrently on the shared HttpResponse would interleave and corrupt the
stream, so a SemaphoreSlim gate serializes the writes (not the model work).

- SseHelper.TryWriteSse takes an optional write-lock (default null, backward
  compatible)
- NonStreamingLoopCallbacks carries SseGate + MemberIndex; _text/_tool/_error
  events are tagged with the member index (review/finalizer stay at -1, no gate)
- CLI members forward TextDelta/ToolStart/ToolResult live instead of
  discarding everything but the final text
- Anthropic-API members stream per tool-round via the loop callbacks; OpenAI
  members fall back to collected text at panel_member_done
- Frontend demuxes by member index into per-bubble interleaved text/tool
  rendering

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.8 committed 2026-06-18 13:18:13 +02:00
1 parent 268f11fa6d
commit f21ed9ff9f
5 files changed
+183 -34

No files matched your search

+17 -4
View File
@@ -25,17 +25,30 @@ public static class SseHelper
/// <summary>
/// Versucht ein SSE-Event zu schreiben, ignoriert Fehler (Client disconnected).
/// Optionaler <paramref name="gate"/>: serialisiert die Byte-Writes, wenn mehrere
/// Producer (z.B. parallele Panel-Member) auf DIESELBE HttpResponse schreiben –
/// sonst vermischen sich die SSE-Frames auf Byte-Ebene.
/// </summary>
public static async Task TryWriteSse(HttpResponse response, string eventType, object data, CancellationToken ct)
public static async Task TryWriteSse(HttpResponse response, string eventType, object data, CancellationToken ct, SemaphoreSlim gate = null)
{
if (ct.IsCancellationRequested) return;
bool acquired = false;
try
{
if (!ct.IsCancellationRequested)
await WriteSseEvent(response, eventType, data, ct);
if (gate != null)
{
await gate.WaitAsync(ct);
acquired = true;
}
await WriteSseEvent(response, eventType, data, ct);
}
catch
{
// Client bereits disconnected – ignorieren
// Client bereits disconnected / abgebrochen – ignorieren
}
finally
{
if (acquired) gate.Release();
}
}