From 0ef309092121c748a27ad2c39b0f4e9df1c95ed4 Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Thu, 24 Sep 2026 14:02:08 -0700 Subject: [PATCH 1/3] Stream replies into the transcript and split them at tool calls While a turn streams, its text shows in a live draft card that repaints about ten times a second and is never journaled. When the model starts a tool call mid-reply, the text so far becomes its own card ahead of the tool pill, so words written before a tool call no longer merge into the answer written after it. Those cards stay provisional until the turn settles: they are kept only if the final text contains them, in order, and removed otherwise, so a text-written tool call never stays on screen. Depends on the engine's OnResponseTextDelta, IsStreamingModelCall and ReplyText (DevMando/MandoCode feature/stream-reply-split); the submodule points at that branch until it merges. Co-Authored-By: Claude Opus 5.5 (1M context) --- MandoCode | 2 +- .../ResponseStreamerTests.cs | 118 ++++++++++++++++- .../Assets/web/transcript/transcript.css | 4 + .../Assets/web/transcript/transcript.js | 59 +++++++++ .../Controls/ChatTabView.Transcript.cs | 57 +++++++++ .../Controls/ChatTabView.xaml.cs | 9 ++ .../Services/AgentSession.cs | 1 + .../Services/AiServiceAdapter.cs | 8 ++ src/MandoCode.Desktop/Services/IAiService.cs | 8 ++ .../Services/TranscriptWriter.cs | 24 ++++ .../ViewModels/ChatController.cs | 3 + .../ViewModels/ResponseStreamer.cs | 120 +++++++++++++++++- 12 files changed, 409 insertions(+), 4 deletions(-) diff --git a/MandoCode b/MandoCode index e675782..fd623aa 160000 --- a/MandoCode +++ b/MandoCode @@ -1 +1 @@ -Subproject commit e67578251c5716a6ede192a2e3e82e7dfda7c8f0 +Subproject commit fd623aa68189ef49737e6c192b841e64a93bfcb2 diff --git a/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs b/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs index 16e85cf..a38b2d6 100644 --- a/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs +++ b/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs @@ -32,6 +32,13 @@ private sealed class FakeAiService : IAiService private readonly Exception? _throw; public string? LastHostInstruction { get; private set; } + /// Runs before segment i is yielded: where a test streams chunks and starts + /// tool calls, the way the harness does while a turn is in flight. + public Action? BeforeSegment { get; set; } + + public event Action? OnResponseTextDelta; + public void Emit(string text) => OnResponseTextDelta?.Invoke(text); + public FakeAiService(string[] segments, Exception? throwOnStream = null) { _segments = segments; @@ -46,10 +53,11 @@ public async IAsyncEnumerable ChatStreamAsync( await Task.Yield(); throw _throw; } - foreach (var s in _segments) + for (int i = 0; i < _segments.Length; i++) { await Task.Yield(); - yield return s; + BeforeSegment?.Invoke(i); + yield return _segments[i]; } } @@ -107,6 +115,112 @@ public async Task EachTurn_BecomesItsOwnCard_AndReturnsJoinedText() Assert.Equal(new[] { "a:hello", "a:world" }, logged); } + private sealed class LiveLog + { + public readonly List Events = new(); + + public LiveLog(TranscriptWriter transcript) + { + transcript.BlockAdded += b => Add($"block:{b}"); + transcript.BlockJournaled += b => Add($"journal:{b}"); + transcript.LiveTextChanged += (_, t) => Add($"live:{t}"); + transcript.LiveCardSealed += (_, h) => Add($"seal:{h}"); + transcript.LiveEnded += (_, keep) => Add($"end:{keep}"); + } + + private void Add(string e) { lock (Events) Events.Add(e); } + } + + private static (ResponseStreamer streamer, LiveLog log) MakeLive(FakeAiService ai) + { + var transcript = new TranscriptWriter(); + var log = new LiveLog(transcript); + var config = new MandoCodeConfig { EnableTokenTracking = false }; + var streamer = new ResponseStreamer( + ai, transcript, new TagHtml(), new BusyStateService(), new TokenTrackingService(), config); + return (streamer, log); + } + + [Fact] + public async Task TextBeforeAToolCall_BecomesItsOwnCard_AheadOfTheAnswer() + { + var ai = new FakeAiService(new[] { "Let me check the docs.\nHere is the answer." }); + var (s, log) = MakeLive(ai); + ai.BeforeSegment = _ => + { + ai.Emit("Let me check "); + ai.Emit("the docs."); + s.SealLiveText(); // what ChatController.OnFunctionInvoked does + ai.Emit("Here is the answer."); + }; + + var result = await s.StreamAsync("hi", CancellationToken.None); + + Assert.Equal("Let me check the docs.\nHere is the answer.", result); + var settled = log.Events.Where(e => !e.StartsWith("live:")).ToList(); + Assert.Equal(new[] + { + "seal:CARD:Let me check the docs.", + "journal:CARD:Let me check the docs.", + "block:CARD:Here is the answer.", + "end:True", + "end:False", // the next (never-started) turn is closed on the way out + }, settled); + } + + [Fact] + public async Task RewrittenFinalText_DropsProvisionalCards_AndShowsTheFinalText() + { + // A model that writes its tool call as text: the fallback parser strips it after streaming, + // so the provisional card's raw text is not in the final reply and must not stay on screen. + var ai = new FakeAiService(new[] { "The file is updated." }); + var (s, log) = MakeLive(ai); + ai.BeforeSegment = _ => + { + ai.Emit("{\"name\":\"write_file\"}"); + s.SealLiveText(); + }; + + await s.StreamAsync("hi", CancellationToken.None); + + Assert.DoesNotContain(log.Events, e => e.StartsWith("journal:")); + Assert.Contains("block:CARD:The file is updated.", log.Events); + Assert.Equal("end:False", log.Events.First(e => e.StartsWith("end:"))); + } + + [Fact] + public async Task StreamedChunks_RepaintTheDraft_WithTheWholeReplySoFar() + { + var ai = new FakeAiService(new[] { "Hello there" }); + var (s, log) = MakeLive(ai); + s.LiveFlushInterval = TimeSpan.FromMilliseconds(5); + ai.BeforeSegment = _ => + { + ai.Emit("Hello "); + ai.Emit("there"); + Thread.Sleep(200); // let the throttled repaint land before the turn settles + }; + + await s.StreamAsync("hi", CancellationToken.None); + + Assert.Contains("live:Hello there", log.Events); + Assert.DoesNotContain(log.Events, e => e.StartsWith("seal:")); + Assert.Contains("block:CARD:Hello there", log.Events); + } + + [Fact] + public async Task SealWithoutAStreamingTurn_IsANoOp() + { + var ai = new FakeAiService(new[] { "done" }); + var (s, log) = MakeLive(ai); + + s.SealLiveText(); + await s.StreamAsync("hi", CancellationToken.None); + s.SealLiveText(); + + Assert.DoesNotContain(log.Events, e => e.StartsWith("seal:")); + } + [Fact] public async Task HostInstruction_UsesSeparateAiServiceChannel() { diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css index 52c1205..e9cb92e 100644 --- a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css +++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css @@ -541,6 +541,10 @@ .assistant { margin-top: 4px; position: relative; } .assistant-label { color: var(--green); font-weight: 700; margin-bottom: 2px; } .md p { margin: 6px 0; } + /* Live reply draft: plain text until the turn settles into a rendered card. No e-ink filter — + the draft repaints while it streams, and a filter would re-rasterize it on every repaint. */ + .live-text { white-space: pre-wrap; overflow-wrap: anywhere; } + html[data-flat] .live-draft .md { filter: none; } .md pre { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; overflow-x: auto; position: relative; diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.js b/src/MandoCode.Desktop/Assets/web/transcript/transcript.js index f60f9f8..7ca0967 100644 --- a/src/MandoCode.Desktop/Assets/web/transcript/transcript.js +++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.js @@ -507,6 +507,7 @@ const wrap = document.createElement('div'); wrap.innerHTML = html; while (wrap.firstChild) placeChild(wrap.firstChild); + keepDraftLast(); highlightNew(); linkifyPaths(); addCopyChips(); @@ -523,6 +524,64 @@ }; window.__clear = function () { log.innerHTML = ''; updatePill(); }; + // --- live reply draft: the streaming turn's text, repainted in place (throttled by the host) --- + // Plain text in one text node, so each repaint is a single node swap with no markdown or + // highlighting pass. Each turn has a generation number and an ended one never repaints: the host + // can deliver a late throttled update after the turn's end, and it must not bring the draft back. + let liveEnded = 0; + function keepDraftLast() { + const draft = document.getElementById('live-draft'); + if (draft && draft !== log.lastElementChild) log.appendChild(draft); + } + window.__live = function (gen, label, text) { + if (gen <= liveEnded) return; + const nearBottom = (window.innerHeight + window.scrollY) >= (document.body.scrollHeight - 60); + let draft = document.getElementById('live-draft'); + if (!draft) { + draft = document.createElement('div'); + draft.id = 'live-draft'; + // data-copy/data-rx pre-set: copy chips and reactions belong on the real card, not the draft. + draft.className = 'assistant live-draft'; + draft.setAttribute('data-copy', '1'); + draft.setAttribute('data-rx', '1'); + draft.title = new Date().toLocaleTimeString(); + const name = document.createElement('div'); + name.className = 'assistant-label'; + name.textContent = label; + const body = document.createElement('div'); + // .md so every theme's message padding and type apply; data-fl pre-set so path + // linkifying skips it (the next repaint would wipe the links anyway). + body.className = 'md live-text'; + body.setAttribute('data-fl', '1'); + draft.appendChild(name); + draft.appendChild(body); + log.appendChild(draft); + } + draft.querySelector('.live-text').textContent = text; + if (nearBottom) window.scrollTo(0, document.body.scrollHeight); + updatePill(); + }; + // The draft so far became a real card because a tool call started. It stays provisional (marked + // with its turn) until __liveEnd decides whether the turn's final text agreed with it. + window.__liveSeal = function (gen, html) { + if (gen <= liveEnded) return; + const draft = document.getElementById('live-draft'); + if (draft) draft.remove(); + const before = log.lastElementChild; + window.__append(html); + for (let n = before ? before.nextElementSibling : log.firstElementChild; n; n = n.nextElementSibling) + n.setAttribute('data-live-seg', String(gen)); + }; + window.__liveEnd = function (gen, keep) { + if (gen > liveEnded) liveEnded = gen; + const draft = document.getElementById('live-draft'); + if (draft) draft.remove(); + log.querySelectorAll('[data-live-seg="' + gen + '"]').forEach(function (n) { + if (keep) n.removeAttribute('data-live-seg'); else n.remove(); + }); + updatePill(); + }; + document.addEventListener('click', function (e) { const link = e.target.closest('a[data-file]'); if (!link) return; diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs index 5d06354..c4e5d95 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs @@ -188,6 +188,63 @@ private async void AppendHtml(string html) } } + // ============================================================ + // Live reply draft (the streaming turn; see ResponseStreamer) + // ============================================================ + + // Provisional cards of the current live turn that went to the pending queue because the + // WebView wasn't ready (a background agent's tab). Queued in place so they keep their order + // ahead of the turn's final card; pulled back out if the turn settles against them. + private readonly List _queuedLiveSeals = new(); + private long _queuedLiveGen; + + // Live repaints are never queued: a draft only matters while it is visible, and the settled + // cards replace it either way. + private void UpdateLiveDraft(long gen, string text) + { + var core = CanScript ? TranscriptView.CoreWebView2 : null; + if (core == null) return; + var label = Session.Config.AgentName ?? "MandoCode"; + _ = RunTranscriptScriptAsync(core, + $"window.__live({gen}, {JsonSerializer.Serialize(label)}, {JsonSerializer.Serialize(text)})"); + } + + private void SealLiveDraft(long gen, string html) + { + var core = CanScript ? TranscriptView.CoreWebView2 : null; + if (core == null) + { + if (gen != _queuedLiveGen) { _queuedLiveSeals.Clear(); _queuedLiveGen = gen; } + _queuedLiveSeals.Add(html); + _pendingHtml.Enqueue(html); + return; + } + _ = RunTranscriptScriptAsync(core, $"window.__liveSeal({gen}, {JsonSerializer.Serialize(html)})"); + } + + private void EndLiveDraft(long gen, bool keep) + { + if (gen == _queuedLiveGen && _queuedLiveSeals.Count > 0) + { + if (!keep) + { + var rest = _pendingHtml.Where(b => !_queuedLiveSeals.Any(s => ReferenceEquals(s, b))).ToList(); + _pendingHtml.Clear(); + foreach (var block in rest) _pendingHtml.Enqueue(block); + } + _queuedLiveSeals.Clear(); + } + var core = CanScript ? TranscriptView.CoreWebView2 : null; + if (core == null) return; + _ = RunTranscriptScriptAsync(core, $"window.__liveEnd({gen}, {(keep ? "true" : "false")})"); + } + + private static async Task RunTranscriptScriptAsync(Microsoft.Web.WebView2.Core.CoreWebView2 core, string script) + { + try { await core.ExecuteScriptAsync(script); } + catch { /* transient during navigation/teardown */ } + } + /// Collapses the current group of routine tool/status output without touching visible /// messages that need attention. If a turn ends before WebView initialization, apply it after /// the queued blocks have reached the document. diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs index 34dcfec..ca57f06 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs @@ -114,6 +114,9 @@ public ChatTabView(Window owner, AgentSession session, TranscriptHtmlBuilder htm _transcript.BlockAdded += OnTranscriptBlock; _transcript.Cleared += OnTranscriptCleared; _transcript.ActivityCompleted += OnTranscriptActivityCompleted; + _transcript.LiveTextChanged += OnLiveText; + _transcript.LiveCardSealed += OnLiveSealed; + _transcript.LiveEnded += OnLiveEnded; Session.Busy.Changed += OnBusyChanged; Session.TitleChanged += OnAgentTitleChanged; Session.PreviewTools.ExecuteAsync = DispatchPreviewRequestAsync; @@ -133,6 +136,9 @@ public ChatTabView(Window owner, AgentSession session, TranscriptHtmlBuilder htm // Harness events arrive on background threads; each hop marshals to the UI thread. private void OnTranscriptBlock(string html) => OnUi(() => AppendHtml(html)); private void OnTranscriptCleared() => OnUi(ClearTranscript); + private void OnLiveText(long gen, string text) => OnUi(() => UpdateLiveDraft(gen, text)); + private void OnLiveSealed(long gen, string html) => OnUi(() => SealLiveDraft(gen, html)); + private void OnLiveEnded(long gen, bool keep) => OnUi(() => EndLiveDraft(gen, keep)); private void OnTranscriptActivityCompleted() => OnUi(() => { CompleteTranscriptActivity(); @@ -357,6 +363,9 @@ public void Shutdown() _transcript.BlockAdded -= OnTranscriptBlock; _transcript.Cleared -= OnTranscriptCleared; _transcript.ActivityCompleted -= OnTranscriptActivityCompleted; + _transcript.LiveTextChanged -= OnLiveText; + _transcript.LiveCardSealed -= OnLiveSealed; + _transcript.LiveEnded -= OnLiveEnded; Session.Busy.Changed -= OnBusyChanged; Session.TitleChanged -= OnAgentTitleChanged; Session.PreviewTools.ExecuteAsync = null; diff --git a/src/MandoCode.Desktop/Services/AgentSession.cs b/src/MandoCode.Desktop/Services/AgentSession.cs index 3986fb0..57bcb2d 100644 --- a/src/MandoCode.Desktop/Services/AgentSession.cs +++ b/src/MandoCode.Desktop/Services/AgentSession.cs @@ -204,6 +204,7 @@ public AgentSession( // Journal every transcript block as it's written (tier-2 session persistence). // /clear also clears the on-disk history — cleared means cleared, both files. Transcript.BlockAdded += htmlBlock => TranscriptJournal.Append(PersistKey, htmlBlock); + Transcript.BlockJournaled += htmlBlock => TranscriptJournal.Append(PersistKey, htmlBlock); Transcript.Cleared += () => { TranscriptJournal.Delete(PersistKey); diff --git a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs index f3e0d85..f7a3258 100644 --- a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs +++ b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs @@ -34,6 +34,14 @@ public event Action? OnFunctionCompleted remove => _ai.OnFunctionCompleted -= value; } + public event Action? OnResponseTextDelta + { + add => _ai.OnResponseTextDelta += value; + remove => _ai.OnResponseTextDelta -= value; + } + + public bool IsStreamingModelCall => _ai.IsStreamingModelCall; + public Func>? OnWriteApprovalRequested { get => _ai.OnWriteApprovalRequested; diff --git a/src/MandoCode.Desktop/Services/IAiService.cs b/src/MandoCode.Desktop/Services/IAiService.cs index 6a03cd4..f171723 100644 --- a/src/MandoCode.Desktop/Services/IAiService.cs +++ b/src/MandoCode.Desktop/Services/IAiService.cs @@ -38,6 +38,14 @@ bool TryAttachImage(ReadOnlyMemory bytes, string mediaType, string caption event Action? OnFunctionInvoked; event Action? OnFunctionCompleted; + /// Reply text as a chat turn streams — a preview only; the turn's yielded string stays + /// authoritative. Default no-op so a fake that doesn't stream needn't implement it. + event Action? OnResponseTextDelta { add { } remove { } } + + /// True while a model call is streaming. A tool call starting now was made mid-reply, + /// so the text before it is final; a text-written call runs after the stream, when it's false. + bool IsStreamingModelCall => false; + Func>? OnWriteApprovalRequested { get; set; } Func>? OnDeleteApprovalRequested { get; set; } Func>? OnCommandApprovalRequested { get; set; } diff --git a/src/MandoCode.Desktop/Services/TranscriptWriter.cs b/src/MandoCode.Desktop/Services/TranscriptWriter.cs index 19179e2..261c5a8 100644 --- a/src/MandoCode.Desktop/Services/TranscriptWriter.cs +++ b/src/MandoCode.Desktop/Services/TranscriptWriter.cs @@ -18,8 +18,32 @@ public sealed class TranscriptWriter /// surface uses it to collapse activity while leaving important notices and chat visible. public event Action? ActivityCompleted; + /// Raised with the whole reply-so-far of the streaming turn gen. Not journaled: + /// the live draft is replaced by real cards when the turn settles. + public event Action? LiveTextChanged; + + /// Raised when the live draft of turn gen becomes a provisional card (a tool call + /// started). Not journaled until keeps it. + public event Action? LiveCardSealed; + + /// Raised once when turn gen settles: the draft goes away, and its provisional + /// cards are kept (true) or removed because the final text disagreed with them. + public event Action? LiveEnded; + + /// Raised for a block that is already on screen and only needs persisting — a kept + /// provisional card. The journal listens; the transcript surface does not. + public event Action? BlockJournaled; + public void Append(string html) => BlockAdded?.Invoke(html); + public void UpdateLive(long gen, string text) => LiveTextChanged?.Invoke(gen, text); + + public void SealLive(long gen, string html) => LiveCardSealed?.Invoke(gen, html); + + public void EndLive(long gen, bool keepSealed) => LiveEnded?.Invoke(gen, keepSealed); + + public void Journal(string html) => BlockJournaled?.Invoke(html); + public void Clear() => Cleared?.Invoke(); public void CompleteActivity() => ActivityCompleted?.Invoke(); diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs index c0720ed..4af870c 100644 --- a/src/MandoCode.Desktop/ViewModels/ChatController.cs +++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs @@ -896,6 +896,9 @@ private string ProcessFileReferences(string input) private void OnFunctionInvoked(FunctionCall call) { + // Text streamed before this call becomes its own card, ahead of the call's pill. Only for a + // call made mid-stream: a text-written call's raw text is about to be stripped from the reply. + if (_ai.IsStreamingModelCall) _streamer.SealLiveText(); _lastOperationType = call.FunctionName.Replace("FileSystem_", "").ToLowerInvariant(); if (!call.FunctionName.StartsWith("FileSystem_", StringComparison.Ordinal)) diff --git a/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs b/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs index 9059d9c..e260af6 100644 --- a/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs +++ b/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs @@ -1,3 +1,4 @@ +using System.Text; using MandoCode.Models; using MandoCode.Services; using MandoCode.Desktop.Services; @@ -12,6 +13,13 @@ namespace MandoCode.Desktop.ViewModels; /// (the request-lifecycle bits — the CancellationTokenSource, StateChanged, operation-field resets — /// stay in ChatController). The 401 sign-in walkthrough is a UI wizard, so it arrives as the /// callback rather than being called directly. +/// +/// While a turn streams, its text shows in a live draft (, +/// throttled). When a tool call starts, turns the text so far into its own +/// provisional card, so words written before a tool call don't merge into the answer written after +/// it. Provisional cards are only kept if they are found, in order, in the turn's final text: a model +/// that writes its tool calls as text has them parsed out after streaming, and that raw text must +/// not stay on screen. /// public sealed class ResponseStreamer { @@ -38,6 +46,18 @@ public ResponseStreamer( _config = config; } + /// How often the live draft repaints. Each repaint replaces one text node, but a + /// per-token repaint is the continuous-redraw trap, so chunks are coalesced. + public TimeSpan LiveFlushInterval { get; set; } = TimeSpan.FromMilliseconds(100); + + private readonly object _liveLock = new(); + private readonly StringBuilder _liveText = new(); + private readonly List _sealedText = new(); + private readonly List _sealedHtml = new(); + private long _liveGen; + private bool _liveOpen; + private bool _flushPending; + /// Logs conversational turns ("a" for each assistant turn). Set by ChatController so the /// same logger records both user and assistant turns. public Action? ConversationLogger { get; set; } @@ -54,6 +74,8 @@ public async Task StreamAsync(string input, CancellationToken token, str { try { + _ai.OnResponseTextDelta += OnTextDelta; + BeginLiveTurn(); var stream = string.IsNullOrWhiteSpace(hostInstruction) ? _ai.ChatStreamAsync(input, token) : _ai.ChatStreamWithHostInstructionAsync(input, hostInstruction, token); @@ -77,12 +99,18 @@ public async Task StreamAsync(string input, CancellationToken token, str do { var segment = enumerator.Current.Trim(); + var (unshown, endLive) = SettleLiveTurn(segment); if (segment.Length > 0) { segments.Add(segment); - _transcript.Append(_html.AssistantCard(segment, _config.AgentName)); + if (unshown.Length > 0) + _transcript.Append(_html.AssistantCard(unshown, _config.AgentName)); ConversationLogger?.Invoke("a", segment); } + // After the card, so the draft is replaced rather than blinking out first. + endLive(); + // The next turn's chunks only start once MoveNextAsync resumes the harness. + BeginLiveTurn(); } while (await enumerator.MoveNextAsync()); _busy.Stop(); @@ -132,16 +160,106 @@ public async Task StreamAsync(string input, CancellationToken token, str } catch (OperationCanceledException) { + DiscardLiveTurn(); _transcript.Append(_html.Warn("Request cancelled.")); return ""; } catch (Exception ex) { + DiscardLiveTurn(); _transcript.Append(_html.Error($"Error: {ex.Message}")); return ""; } + finally + { + _ai.OnResponseTextDelta -= OnTextDelta; + DiscardLiveTurn(); + } + } + + /// A tool call is starting: the text streamed so far becomes its own provisional card, + /// ahead of the tool pill the caller appends next. No-op when nothing is streaming. + public void SealLiveText() + { + lock (_liveLock) + { + if (!_liveOpen) return; + var text = _liveText.ToString().Trim(); + _liveText.Clear(); + if (text.Length == 0) return; + var html = _html.AssistantCard(text, _config.AgentName); + _sealedText.Add(text); + _sealedHtml.Add(html); + _transcript.SealLive(_liveGen, html); + } } + private void OnTextDelta(string text) + { + lock (_liveLock) + { + if (!_liveOpen) return; + _liveText.Append(text); + if (_flushPending) return; + _flushPending = true; + _ = FlushLiveLaterAsync(_liveGen); + } + } + + private async Task FlushLiveLaterAsync(long gen) + { + await Task.Delay(LiveFlushInterval).ConfigureAwait(false); + lock (_liveLock) + { + if (gen != _liveGen || !_liveOpen) return; // the turn settled while we waited + _flushPending = false; + var text = _liveText.ToString(); + if (text.Trim().Length > 0) _transcript.UpdateLive(gen, text); + } + } + + private void BeginLiveTurn() + { + lock (_liveLock) + { + _liveGen++; + _liveOpen = true; + _flushPending = false; + _liveText.Clear(); + _sealedText.Clear(); + _sealedHtml.Clear(); + } + } + + /// Closes the live turn against its authoritative text. Returns the part of it that + /// still needs a card (all of it, unless provisional cards were kept) and the call that clears + /// the draft, which the caller makes once that card is appended. + private (string Unshown, Action EndLive) SettleLiveTurn(string finalText) + { + lock (_liveLock) + { + if (!_liveOpen) return (finalText, () => { }); + _liveOpen = false; + string? rest = null; + var keep = _sealedText.Count > 0 && ReplyText.TryRemoveInOrder(finalText, _sealedText, out rest); + if (keep) + foreach (var html in _sealedHtml) _transcript.Journal(html); + var gen = _liveGen; + return (keep ? rest!.Trim() : finalText, () => _transcript.EndLive(gen, keep)); + } + } + + private void DiscardLiveTurn() + { + lock (_liveLock) + { + if (!_liveOpen) return; + _liveOpen = false; + _transcript.EndLive(_liveGen, false); + } + } + + private static bool Looks401(string responseText) => !string.IsNullOrEmpty(responseText) && responseText.Contains("401 Unauthorized", StringComparison.OrdinalIgnoreCase); From 984d532cefaaebbfaf6c06bf5aab4bd7be52d2ad Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Thu, 24 Sep 2026 15:29:08 -0700 Subject: [PATCH 2/3] Docs: record streamed replies and split cards in the changelog Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 200f761..949692c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ submodule. ## [Unreleased] +### Added +- **Watch replies as they're written.** The agent's reply now appears in the transcript as it + streams, instead of all at once when it's finished. If the agent says something before using a + tool ("let me check the docs…"), that text becomes its own card above the tool call, and the + answer gets a second card. The conversation reads in the order it happened, instead of merging + everything into one card at the end. + ## [0.15.0] — 2026-09-10 **A new AI foundation, plans you can resume, and more ways to work alongside your agents.** From d81e8a49c68df012b54f2bdd263c8b68e7e9087d Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Thu, 24 Sep 2026 17:25:49 -0700 Subject: [PATCH 3/3] Chore: pin the engine to the merged streaming and CLI UI work Points the MandoCode submodule at engine main 9904aad, which carries DevMando/MandoCode#99 (reply streaming hooks and ReplyText), #100 (tok/s and cut-off notices while streaming) and #101 (CLI live UI and ContextMeter). Co-Authored-By: Claude Opus 5.5 (1M context) --- MandoCode | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MandoCode b/MandoCode index fd623aa..9904aad 160000 --- a/MandoCode +++ b/MandoCode @@ -1 +1 @@ -Subproject commit fd623aa68189ef49737e6c192b841e64a93bfcb2 +Subproject commit 9904aad8c187bddef7c00fe8d2f88bd8f8af4904