From 5b51d58555a73dff3d5db1ab0d3ff41599a0d484 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 11 Sep 2026 14:16:07 +0200 Subject: [PATCH 1/2] Improve desktop chat activity, interruption, and queued messages --- src/Capacitor.App/ViewModels/ChatInput.cs | 2 + .../ViewModels/ChatTabViewModel.cs | 103 +++++++++++------ .../ViewModels/QueuedChatMessage.cs | 24 ++++ .../ViewModels/RailSessionViewModel.cs | 7 ++ .../ViewModels/RailWorktreeViewModel.cs | 10 ++ .../ViewModels/TerminalChatInput.cs | 4 + .../ViewModels/TerminalTabViewModel.cs | 17 +++ src/Capacitor.App/Views/ChatTabView.axaml | 24 +++- src/Capacitor.App/Views/ChatTabView.axaml.cs | 6 + src/Capacitor.App/Views/SessionRailView.axaml | 4 +- src/Capacitor.Cli.Core/JsonlTail.cs | 20 +++- .../ChatComposerTests.cs | 1 + .../ChatTabViewModelTests.cs | 108 +++++++++++++++--- .../ChatTabViewSmokeTests.cs | 35 ++++++ .../RailSessionViewModelTests.cs | 10 ++ .../TerminalChatInputTests.cs | 37 ++++++ .../JsonlTailTests.cs | 10 ++ 17 files changed, 366 insertions(+), 56 deletions(-) create mode 100644 src/Capacitor.App/ViewModels/QueuedChatMessage.cs diff --git a/src/Capacitor.App/ViewModels/ChatInput.cs b/src/Capacitor.App/ViewModels/ChatInput.cs index feab69adb6..2387bfebba 100644 --- a/src/Capacitor.App/ViewModels/ChatInput.cs +++ b/src/Capacitor.App/ViewModels/ChatInput.cs @@ -11,5 +11,7 @@ public abstract class ChatInput : ReactiveObject, IDisposable { public abstract string Hint { get; } /// Completes when the channel considers the text committed: true to clear the composer. public abstract Task SendAsync(string text, CancellationToken ct); + public virtual bool CanInterrupt => false; + public virtual Task InterruptAsync(CancellationToken ct) => Task.CompletedTask; public abstract void Dispose(); } diff --git a/src/Capacitor.App/ViewModels/ChatTabViewModel.cs b/src/Capacitor.App/ViewModels/ChatTabViewModel.cs index 15cb1da03a..96109a466d 100644 --- a/src/Capacitor.App/ViewModels/ChatTabViewModel.cs +++ b/src/Capacitor.App/ViewModels/ChatTabViewModel.cs @@ -44,6 +44,7 @@ public sealed class ChatTabViewModel : ReactiveObject { // token it can ask without an ObjectDisposedException. readonly CancellationToken _lifetimeToken; readonly AvaloniaList _items = new(); + readonly AvaloniaList _queuedMessages = new(); readonly Dictionary _pendingTools = new(StringComparer.Ordinal); // Every tool id with a result, not only the running ones: a replayed request can arrive after // the transcript's initial load, and then only this set can tell that its tool is done. @@ -85,6 +86,15 @@ public TranscriptContext ContextFor(IChatTranscriptProjection projection, string readonly BehaviorSubject _rootSubject = new(null); public IAvaloniaReadOnlyList Items => _items; + public IAvaloniaReadOnlyList QueuedMessages => _queuedMessages; + public bool HasQueuedMessages => _queuedMessages.Count > 0; + public string QueueSummary => $"{_queuedMessages.Count} message{(_queuedMessages.Count == 1 ? "" : "s")} " + + (SessionStatusDots.IsTerminal(_status) ? "unconfirmed" : "queued"); + + void RefreshQueue() { + this.RaisePropertyChanged(nameof(HasQueuedMessages)); + this.RaisePropertyChanged(nameof(QueueSummary)); + } public ReadOnlyObservableCollection PendingCards { get; } public IObservable Root => _rootSubject; @@ -122,6 +132,7 @@ public string ComposerText { } public ReactiveCommand SendCommand { get; } + public ReactiveCommand InterruptCommand { get; } public ReactiveCommand OpenLinkCommand { get; } readonly ObservableAsPropertyHelper _composerHint; @@ -145,34 +156,26 @@ public string ComposerText { public string StatusText { get => _statusText; private set => this.RaiseAndSetIfChanged(ref _statusText, value); } string _activityNote = ""; - /// One line under the rows for the stretches the transcript itself shows nothing: the agent - /// starting, or a turn in flight before its first output. "" whenever the rows speak for themselves. + /// Live elapsed time throughout a busy turn, including while output is streaming. public string ActivityNote { get => _activityNote; private set => this.RaiseAndSetIfChanged(ref _activityNote, value); } string _status = ""; bool? _awaitingInput; - string? _transcriptFormat; - - void RefreshActivityNote() => - ActivityNote = ActivityNoteFor( - Phase, _status, _awaitingInput, _transcriptFormat, - _items.Count > 0 && _items[^1] is UserTurnItem, HasPendingCards, VendorLabel); - - /// The working line is offered only for the daemon's own journal: there the awaiting flag flips - /// on every turn end, whereas a PTY vendor's comes from hooks that may never fire, and a note - /// that never clears is worse than none. It shows only while the tail row is the user's own - /// prompt — the agent has been given something and produced nothing yet. The first assistant or - /// tool row makes the transcript itself the sign of life, so the note clears rather than sitting - /// beside the output. - internal static string ActivityNoteFor( - ChatTabPhase phase, string status, bool? awaitingInput, string? transcriptFormat, - bool awaitingFirstOutput, bool hasPendingCards, string vendorLabel) { - if (phase != ChatTabPhase.Reading) return ""; - if (status == "Starting") return vendorLabel.Length > 0 ? $"Starting {vendorLabel}…" : "Starting…"; - var working = status == "Running" && transcriptFormat == TranscriptFormats.Envelopes - && awaitingInput == false && awaitingFirstOutput && !hasPendingCards; - if (!working) return ""; - return vendorLabel.Length > 0 ? $"{vendorLabel} is working…" : "Working…"; + long? _workingSince; + + void RefreshActivityNote() { + var working = _status == "Running" && _awaitingInput == false; + if (working) _workingSince ??= _time.GetTimestamp(); + else _workingSince = null; + ActivityNote = _status == "Starting" + ? VendorLabel.Length > 0 ? $"Starting {VendorLabel}…" : "Starting…" + : working && !HasPendingCards && _workingSince is { } since + ? WorkingNote(_time.GetElapsedTime(since)) : ""; + } + + static string WorkingNote(TimeSpan elapsed) { + var seconds = Math.Max(0, (long)elapsed.TotalSeconds); + return $"Working for {seconds / 60}m {seconds % 60}s"; } bool _isReadOnlyParticipant; @@ -281,14 +284,14 @@ public ChatTabViewModel( .Subscribe(OnAgentsChanged) .DisposeWith(_disposables); - if (projection is not null) - _timer = time.CreateTimer(_ => OnTick(), null, PollInterval, PollInterval); + _timer = time.CreateTimer(_ => OnTick(), null, PollInterval, PollInterval); daemon.Snapshots .ObserveOn(RxSchedulers.MainThreadScheduler) .Subscribe(snapshot => { _options = HostedHarnessCatalog.Build(snapshot.Daemon.SupportedVendors); VendorLabel = HostedHarnessCatalog.LabelFor(_options, _vendor); + RefreshActivityNote(); }) .DisposeWith(_disposables); @@ -321,19 +324,40 @@ public ChatTabViewModel( SendCommand = ReactiveCommand.CreateFromTask(async () => { var snapshot = ComposerText; var edits = _composerEdits; - bool committed; + var queued = new QueuedChatMessage(snapshot, _path); + _queuedMessages.Add(queued); + RefreshQueue(); + var committed = false; try { committed = await _input.SendAsync(snapshot, _lifetimeToken); } catch (OperationCanceledException) { return; } + finally { + // Without a transcript there is no later echo to observe; the channel's ack is + // the only delivery evidence available. Refusals keep the original draft below. + if (!committed || _projection is null) _queuedMessages.Remove(queued); + RefreshQueue(); + } if (committed && _composerEdits == edits && ComposerText == snapshot) ComposerText = ""; }, canSend); _disposables.Add(SendCommand); + var canInterrupt = Observable.CombineLatest( + _input.WhenAnyValue(i => i.CanInterrupt), + this.WhenAnyValue(x => x.IsReadOnlyParticipant), + (can, readOnly) => can && !readOnly); + InterruptCommand = ReactiveCommand.CreateFromTask(() => _input.InterruptAsync(_lifetimeToken), canInterrupt); + _disposables.Add(InterruptCommand); + OpenLinkCommand = ReactiveCommand.Create(url => LinkPolicy.Open(_opener, url)); _disposables.Add(OpenLinkCommand); } void OnAgentsChanged(IChangeSet changes) { foreach (var change in changes) { + if (change.Key == _agentId && change.Reason == ChangeReason.Remove) { + _status = "Completed"; + RefreshActivityNote(); + RefreshQueue(); + } if (change.Key != _agentId || change.Reason is not (ChangeReason.Add or ChangeReason.Update)) continue; OnDto(change.Current); } @@ -356,9 +380,9 @@ void OnDto(AgentStatusDto dto) { StatusDot = SessionStatusDots.For(dto.Status); _status = dto.Status; _awaitingInput = dto.AwaitingInput; - _transcriptFormat = dto.TranscriptFormat; if (_projection is not null && dto.TranscriptPath is { } path && path != _path) SwitchPath(path); RefreshActivityNote(); + RefreshQueue(); } void SwitchPath(string path) { @@ -378,6 +402,12 @@ void SwitchPath(string path) { } void OnTick() { + if (!Dispatcher.UIThread.CheckAccess()) { + Dispatcher.UIThread.Post(OnTick); + return; + } + if (_lifetimeToken.IsCancellationRequested) return; + RefreshActivityNote(); if (_lease is not { } lease || _projection is not { } projection) return; if (Interlocked.CompareExchange(ref _readInFlight, 1, 0) != 0) return; _pendingRead = ReadAndApplyAsync(lease, projection); @@ -388,14 +418,18 @@ async Task ReadAndApplyAsync(TailLease lease, IChatTranscriptProjection projecti var (read, envelopes) = await Task.Run(() => { var result = lease.Tail.ReadAppended(); if (result.Status == TailStatus.Reset) lease.Reset(); - var list = new List(); + var list = new List<(AcpEventEnvelope Envelope, long Offset)>(); if (result.Lines.Count > 0) { var context = lease.ContextFor(projection, _agentId); context.BeginBatch(); var receivedAt = _time.GetUtcNow(); - foreach (var line in result.Lines) { + for (var index = 0; index < result.Lines.Count; index++) { + var line = result.Lines[index]; var lineNumber = lease.NextLine(); - try { list.AddRange(projection.Project(line, lineNumber, receivedAt, context)); } + try { + foreach (var envelope in projection.Project(line, lineNumber, receivedAt, context)) + list.Add((envelope, result.LineEndOffsets[index])); + } catch (Exception ex) { LogOnce($"projection: {ex.Message}"); } } } @@ -410,7 +444,7 @@ async Task ReadAndApplyAsync(TailLease lease, IChatTranscriptProjection projecti } } - void Apply(int generation, TailRead read, List envelopes) { + void Apply(int generation, TailRead read, List<(AcpEventEnvelope Envelope, long Offset)> envelopes) { if (generation != Volatile.Read(ref _generation)) return; switch (read.Status) { @@ -436,9 +470,11 @@ void Apply(int generation, TailRead read, List envelopes) { } var fresh = new List(); - foreach (var e in envelopes) { + foreach (var (e, offset) in envelopes) { switch (e.Kind) { case AcpEventKind.UserMessage: + var acknowledged = _queuedMessages.FirstOrDefault(q => q.Matches(e.Text ?? "", _path, offset)); + if (acknowledged is not null) _queuedMessages.Remove(acknowledged); _openGroup = null; fresh.Add(new UserTurnItem(e.Text ?? "")); break; @@ -470,6 +506,7 @@ void Apply(int generation, TailRead read, List envelopes) { } } if (fresh.Count > 0) _items.AddRange(fresh); + RefreshQueue(); Reconcile(); RefreshActivityNote(); } diff --git a/src/Capacitor.App/ViewModels/QueuedChatMessage.cs b/src/Capacitor.App/ViewModels/QueuedChatMessage.cs new file mode 100644 index 0000000000..b7807d2b55 --- /dev/null +++ b/src/Capacitor.App/ViewModels/QueuedChatMessage.cs @@ -0,0 +1,24 @@ +namespace Capacitor.App.ViewModels; + +/// An outgoing message stays here until the transcript acknowledges it. Transport acceptance +/// alone does not mean the runtime has started consuming a prompt queued behind its current turn. +public sealed class QueuedChatMessage { + public string Text { get; } + internal string? TranscriptPath { get; } + internal long TranscriptOffset { get; } + + public QueuedChatMessage(string text, string? transcriptPath) { + Text = text; + TranscriptPath = transcriptPath; + if (transcriptPath is null) return; + try { TranscriptOffset = new FileInfo(transcriptPath).Length; } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + internal bool Matches(string text, string? path, long offset) => + (TranscriptPath != path || offset > TranscriptOffset) + && Normalize(Text) == Normalize(text); + + static string Normalize(string text) => text.Replace("\r\n", "\n").Trim(); +} diff --git a/src/Capacitor.App/ViewModels/RailSessionViewModel.cs b/src/Capacitor.App/ViewModels/RailSessionViewModel.cs index 4840a616ad..eff26a4219 100644 --- a/src/Capacitor.App/ViewModels/RailSessionViewModel.cs +++ b/src/Capacitor.App/ViewModels/RailSessionViewModel.cs @@ -32,6 +32,9 @@ public sealed class RailSessionViewModel : ReactiveObject, IDisposable { readonly ObservableAsPropertyHelper _needsYou; public bool NeedsYou => _needsYou.Value; + readonly ObservableAsPropertyHelper _statusBadge; + public string StatusBadge => _statusBadge.Value; + readonly CompositeDisposable _disposables = new(); public RailSessionViewModel( @@ -62,6 +65,10 @@ public RailSessionViewModel( _needsYou = agentsWithPending.Select(set => byStatus || set.Contains(row.Id)) .ToProperty(this, x => x.NeedsYou, initialValue: byStatus) .DisposeWith(_disposables); + _statusBadge = agentsWithPending.Select(set => row.Status == "Failed" || set.Contains(row.Id) + ? "!" : SessionStatusDots.WaitsOnUser(row) ? "zzz" : "") + .ToProperty(this, x => x.StatusBadge, initialValue: SessionStatusDots.WaitsOnUser(row) ? "zzz" : "") + .DisposeWith(_disposables); // Remote rows are read-only in-app; opening deep-links to the web. OpenCommand = ReactiveCommand.Create(() => (IsRemote ? openRemoteInWeb : openLocal)(row.Id)); diff --git a/src/Capacitor.App/ViewModels/RailWorktreeViewModel.cs b/src/Capacitor.App/ViewModels/RailWorktreeViewModel.cs index 0cc164ca63..a44bc775b6 100644 --- a/src/Capacitor.App/ViewModels/RailWorktreeViewModel.cs +++ b/src/Capacitor.App/ViewModels/RailWorktreeViewModel.cs @@ -35,6 +35,9 @@ public sealed class RailWorktreeViewModel : ReactiveObject, IDisposable { readonly ObservableAsPropertyHelper _needsYou; public bool NeedsYou => _needsYou.Value; + readonly ObservableAsPropertyHelper _statusBadge; + public string StatusBadge => _statusBadge.Value; + readonly ObservableAsPropertyHelper _holdsSelected; public bool HoldsSelected => _holdsSelected.Value; @@ -103,6 +106,13 @@ public RailWorktreeViewModel( .ToProperty(this, x => x.NeedsYou, initialValue: false) .DisposeWith(_disposables); + _statusBadge = sessionsCache.Connect().QueryWhenChanged() + .CombineLatest(agentsWithPending, (q, set) => + q.Items.Any(r => r.Status == "Failed" || set.Contains(r.Id)) ? "!" + : q.Items.Any(SessionStatusDots.WaitsOnUser) ? "zzz" : "") + .ToProperty(this, x => x.StatusBadge, initialValue: "") + .DisposeWith(_disposables); + _holdsSelected = sessionsCache.Connect().QueryWhenChanged() .CombineLatest(selectedAgentId, (q, sel) => sel is not null && q.Items.Any(r => r.Id == sel)) .ToProperty(this, x => x.HoldsSelected, initialValue: false) diff --git a/src/Capacitor.App/ViewModels/TerminalChatInput.cs b/src/Capacitor.App/ViewModels/TerminalChatInput.cs index 1e817027f9..c15368750a 100644 --- a/src/Capacitor.App/ViewModels/TerminalChatInput.cs +++ b/src/Capacitor.App/ViewModels/TerminalChatInput.cs @@ -17,11 +17,15 @@ public TerminalChatInput(TerminalTabViewModel terminal) { this.RaisePropertyChanged(nameof(Availability)); this.RaisePropertyChanged(nameof(CanAcceptText)); this.RaisePropertyChanged(nameof(Hint)); + this.RaisePropertyChanged(nameof(CanInterrupt)); }); } public override SendAvailability Availability => _disposed ? SendAvailability.Ended : _terminal.SendAvailability; public override bool CanAcceptText => !_disposed && _terminal.CanAcceptText; + public override bool CanInterrupt => !_disposed && _terminal.CanInterrupt; + public override Task InterruptAsync(CancellationToken ct) => + CanInterrupt ? _terminal.SendEscapeAsync(ct) : Task.CompletedTask; public override string Hint => HintFor(_terminal.SendAvailability, _terminal.State); /// The terminal path has nothing to cancel: acceptance is synchronous. diff --git a/src/Capacitor.App/ViewModels/TerminalTabViewModel.cs b/src/Capacitor.App/ViewModels/TerminalTabViewModel.cs index 4b2867a550..81928328c1 100644 --- a/src/Capacitor.App/ViewModels/TerminalTabViewModel.cs +++ b/src/Capacitor.App/ViewModels/TerminalTabViewModel.cs @@ -112,6 +112,7 @@ public TerminalSessionState State { /// Bound; the one authority TrySendText itself consults, so can-execute and acceptance can /// never disagree. public bool CanAcceptText => GateOpen && !SendInFlight; + public bool CanInterrupt => GateOpen && State is { Phase: TerminalSessionPhase.Attached, ReadOnly: false }; /// Bound; the gate folded into the state, so a hint built from it is true in every window. public SendAvailability SendAvailability { @@ -136,6 +137,7 @@ public SendAvailability SendAvailability { void RaiseSendProjections() { this.RaisePropertyChanged(nameof(SendInFlight)); this.RaisePropertyChanged(nameof(CanAcceptText)); + this.RaisePropertyChanged(nameof(CanInterrupt)); this.RaisePropertyChanged(nameof(SendAvailability)); } @@ -211,6 +213,21 @@ public bool TrySendText(string text) { return true; } + /// A control key bypasses bracketed paste and submit. The same attach gate protects it from + /// reaching a retired or read-only client, and an in-flight paste must finish before Escape. + public async Task SendEscapeAsync(CancellationToken ct) { + var token = Volatile.Read(ref _openingToken); + if (!CanInterrupt || _client is not { } client || ct.IsCancellationRequested) return; + if (Volatile.Read(ref _openingToken) != token) return; + try { + while (_delivery is { IsCompleted: false } delivery) await delivery.WaitAsync(ct); + if (!CanInterrupt || Volatile.Read(ref _openingToken) != token || ct.IsCancellationRequested) return; + await client.SendInputAsync(new byte[] { 0x1b }); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + catch (Exception ex) { Console.Error.WriteLine($"kcap: composer interrupt failed: {ex.Message}"); } + } + // Paste, wait out the TUI's post-paste Enter suppression, then one CR -- only if nothing // advanced the token meanwhile, since the paste already went to a client a replacement must // never follow with a stray Enter. A fault ends the delivery and never touches State: the diff --git a/src/Capacitor.App/Views/ChatTabView.axaml b/src/Capacitor.App/Views/ChatTabView.axaml index 515a9c0ea1..b00e011221 100644 --- a/src/Capacitor.App/Views/ChatTabView.axaml +++ b/src/Capacitor.App/Views/ChatTabView.axaml @@ -4,7 +4,7 @@ xmlns:views="clr-namespace:Capacitor.App.Views" x:Class="Capacitor.App.Views.ChatTabView" x:DataType="vm:ChatTabViewModel"> - + @@ -360,7 +360,27 @@ - + + + + + + + + + + + + + + + diff --git a/src/Capacitor.App/Views/ChatTabView.axaml.cs b/src/Capacitor.App/Views/ChatTabView.axaml.cs index 8ede0b1b91..18654c2c60 100644 --- a/src/Capacitor.App/Views/ChatTabView.axaml.cs +++ b/src/Capacitor.App/Views/ChatTabView.axaml.cs @@ -67,6 +67,12 @@ void OnScrollChanged(object? sender, ScrollChangedEventArgs e) { /// nothing, leaving the text and the hint that says why. Shift+Enter falls through to the /// TextBox's own newline. void OnComposerKeyDown(object? sender, KeyEventArgs e) { + if (e.Key == Key.Escape && e.KeyModifiers == KeyModifiers.None) { + e.Handled = true; + if (DataContext is ChatTabViewModel chat && ((ICommand)chat.InterruptCommand).CanExecute(null)) + chat.InterruptCommand.Execute().Subscribe(); + return; + } if (e.Key != Key.Enter || e.KeyModifiers.HasFlag(KeyModifiers.Shift)) return; e.Handled = true; if (DataContext is ChatTabViewModel vm && ((ICommand)vm.SendCommand).CanExecute(null)) diff --git a/src/Capacitor.App/Views/SessionRailView.axaml b/src/Capacitor.App/Views/SessionRailView.axaml index 4fde155295..df7bae0317 100644 --- a/src/Capacitor.App/Views/SessionRailView.axaml +++ b/src/Capacitor.App/Views/SessionRailView.axaml @@ -135,7 +135,7 @@ Foreground="{StaticResource KcapFaintBrush}" VerticalAlignment="Center" /> - @@ -176,7 +176,7 @@ - diff --git a/src/Capacitor.Cli.Core/JsonlTail.cs b/src/Capacitor.Cli.Core/JsonlTail.cs index 3bc9498293..4b663a18c3 100644 --- a/src/Capacitor.Cli.Core/JsonlTail.cs +++ b/src/Capacitor.Cli.Core/JsonlTail.cs @@ -4,7 +4,10 @@ namespace Capacitor.Cli.Core; public enum TailStatus { Ok, Reset, Missing, Failed } -public sealed record TailRead(IReadOnlyList Lines, TailStatus Status, string? Failure = null); +public sealed record TailRead(IReadOnlyList Lines, TailStatus Status, string? Failure = null) { + /// Byte positions immediately after each returned line, for correlating newly appended input. + public IReadOnlyList LineEndOffsets { get; init; } = []; +} /// Appended-lines reader over a JSONL file another process is writing. Every open shares /// read/write/delete: a FileShare.Read open would deny the agent its own write handle on @@ -40,9 +43,10 @@ public TailRead ReadAppended() { read += n; } - var lines = SplitCompleteLines(buffer.AsSpan(0, read), out var consumed); + var offsets = new List(); + var lines = SplitCompleteLines(buffer.AsSpan(0, read), out var consumed, offsets, origin); _cursor = origin + consumed; - return new TailRead(lines, status); + return new TailRead(lines, status) { LineEndOffsets = offsets }; } catch (FileNotFoundException) { return new TailRead([], TailStatus.Missing); } catch (DirectoryNotFoundException) { @@ -54,7 +58,10 @@ public TailRead ReadAppended() { /// Complete lines only; `consumed` stops after the last '\n' so an unterminated tail is /// re-read whole once its newline lands. - public static List SplitCompleteLines(ReadOnlySpan bytes, out int consumed) { + public static List SplitCompleteLines(ReadOnlySpan bytes, out int consumed) => + SplitCompleteLines(bytes, out consumed, null, 0); + + static List SplitCompleteLines(ReadOnlySpan bytes, out int consumed, List? offsets, long origin) { var lines = new List(); consumed = 0; var start = 0; @@ -62,7 +69,10 @@ public static List SplitCompleteLines(ReadOnlySpan bytes, out int if (bytes[i] != (byte)'\n') continue; var line = bytes[start..i]; if (line.Length > 0 && line[^1] == (byte)'\r') line = line[..^1]; - if (!IsBlank(line)) lines.Add(Encoding.UTF8.GetString(line)); + if (!IsBlank(line)) { + lines.Add(Encoding.UTF8.GetString(line)); + offsets?.Add(origin + i + 1); + } start = i + 1; consumed = start; } diff --git a/test/Capacitor.App.Tests.Unit/ChatComposerTests.cs b/test/Capacitor.App.Tests.Unit/ChatComposerTests.cs index 993ee3a985..c5643f9745 100644 --- a/test/Capacitor.App.Tests.Unit/ChatComposerTests.cs +++ b/test/Capacitor.App.Tests.Unit/ChatComposerTests.cs @@ -163,6 +163,7 @@ await RunOnUiAsync(async () => { chat.ComposerText = "hello"; await Assert.That(await chat.SendCommand.CanExecute.FirstAsync()).IsFalse(); await Assert.That(chat.ComposerHint).IsEqualTo(""); + await Assert.That(await chat.InterruptCommand.CanExecute.FirstAsync()).IsFalse(); await chat.TeardownAsync(); }); } diff --git a/test/Capacitor.App.Tests.Unit/ChatTabViewModelTests.cs b/test/Capacitor.App.Tests.Unit/ChatTabViewModelTests.cs index fcd0e0a03c..0afd35b87e 100644 --- a/test/Capacitor.App.Tests.Unit/ChatTabViewModelTests.cs +++ b/test/Capacitor.App.Tests.Unit/ChatTabViewModelTests.cs @@ -805,9 +805,7 @@ static AgentStatusDto Hosted(string path, string status, bool? awaitingInput) => TranscriptPath = path, TranscriptFormat = TranscriptFormats.Envelopes, Status = status, AwaitingInput = awaitingInput, }; - /// The note is the only sign of life before the first envelope: a starting agent, then a turn - /// in flight once a prompt row exists, then nothing once the daemon says the agent waits on - /// the user. A running agent with no row yet has nothing in flight, so it gets no note either. + /// Busy state comes from the daemon, so the note remains visible before and during output. [Test] [NotInParallel("AvaloniaSession")] public async Task Activity_note_reads_starting_then_working_and_clears_when_the_agent_waits() { @@ -823,23 +821,22 @@ await RunOnUiAsync(async () => { await Assert.That(h.Chat.ActivityNote).IsEqualTo("Starting Pi…"); await h.PushAsync(Hosted(path, "Running", awaitingInput: false)); - await Assert.That(h.Chat.ActivityNote).IsEqualTo(""); + await Assert.That(h.Chat.ActivityNote).IsEqualTo("Working for 0m 0s"); File.AppendAllText(path, EnvelopeJournalFormat.Write(new AcpEventEnvelope(Kind: AcpEventKind.UserMessage, Text: "hi")) + "\n"); await h.TickAsync(); - await Assert.That(h.Chat.ActivityNote).IsEqualTo("Pi is working…"); + await Assert.That(h.Chat.ActivityNote).StartsWith("Working for "); // The awaiting-input flag hides it (the turn ended and the agent waits on the user)… await h.PushAsync(Hosted(path, "Running", awaitingInput: true)); await Assert.That(h.Chat.ActivityNote).IsEqualTo(""); await h.PushAsync(Hosted(path, "Running", awaitingInput: false)); - await Assert.That(h.Chat.ActivityNote).IsEqualTo("Pi is working…"); + await Assert.That(h.Chat.ActivityNote).IsEqualTo("Working for 0m 0s"); - // …and so does the agent's first output row, mid-turn: the transcript is now the sign of - // life, so the note clears instead of sitting beside the streaming answer. + // Output does not end the timer; only a turn verdict does. File.AppendAllText(path, EnvelopeJournalFormat.Write(new AcpEventEnvelope(Kind: AcpEventKind.AssistantText, Text: "on it")) + "\n"); await h.TickAsync(); - await Assert.That(h.Chat.ActivityNote).IsEqualTo(""); + await Assert.That(h.Chat.ActivityNote).StartsWith("Working for "); await h.TeardownAsync(); }); } @@ -856,7 +853,7 @@ await RunOnUiAsync(async () => { var h = new Harness(TranscriptChat.Journal); await h.PushAsync(Hosted(path, "Running", awaitingInput: false)); await h.TickAsync(); - await Assert.That(h.Chat.ActivityNote).IsEqualTo("Pi is working…"); + await Assert.That(h.Chat.ActivityNote).StartsWith("Working for "); h.Permissions.Add(PermissionEntries.Entry("r1", "a1")); await WaitUntilAsync(() => h.Chat.HasPendingCards, what: "the card"); @@ -864,16 +861,15 @@ await RunOnUiAsync(async () => { h.Permissions.Remove("r1"); await WaitUntilAsync(() => !h.Chat.HasPendingCards, what: "the card gone"); - await Assert.That(h.Chat.ActivityNote).IsEqualTo("Pi is working…"); + await Assert.That(h.Chat.ActivityNote).StartsWith("Working for "); await h.TeardownAsync(); }); } - /// A PTY session's awaiting flag comes from vendor hooks that may never fire, so the working - /// note is not offered there: its terminal already shows the activity. + /// PTY chats use the same busy verdict as the rail and hosted chats. [Test] [NotInParallel("AvaloniaSession")] - public async Task A_pty_session_shows_no_working_note() { + public async Task A_pty_session_shows_the_working_note() { await RunOnUiAsync(async () => { var h = Claude(); var path = Tmp.CreateFile("t.jsonl", [UserLine]); @@ -881,7 +877,91 @@ await RunOnUiAsync(async () => { await h.TickAsync(); await Assert.That(h.Chat.Phase).IsEqualTo(ChatTabPhase.Reading); + await Assert.That(h.Chat.ActivityNote).StartsWith("Working for "); + await h.TeardownAsync(); + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Working_time_ticks_without_a_transcript_and_resets_for_each_turn() { + await RunOnUiAsync(async () => { + var h = new Harness(TranscriptChat.Journal); + var dto = Agent("a1", "pi", hasTerminal: false) with { Status = "Running", AwaitingInput = false }; + await h.PushAsync(dto); + h.Time.Advance(TimeSpan.FromSeconds(65)); + await Assert.That(h.Chat.ActivityNote).IsEqualTo("Working for 1m 5s"); + await h.PushAsync(dto); // repeated snapshots must not restart the clock + await Assert.That(h.Chat.ActivityNote).IsEqualTo("Working for 1m 5s"); + await h.PushAsync(dto with { AwaitingInput = true }); await Assert.That(h.Chat.ActivityNote).IsEqualTo(""); + h.Time.Advance(TimeSpan.FromSeconds(30)); + await h.PushAsync(dto); + await Assert.That(h.Chat.ActivityNote).IsEqualTo("Working for 0m 0s"); + await h.PushAsync(dto with { AwaitingInput = null }); + await Assert.That(h.Chat.ActivityNote).IsEqualTo(""); + await h.PushAsync(dto); + h.Daemon.Agents.Remove("a1"); + await Assert.That(h.Chat.ActivityNote).IsEqualTo(""); + await h.TeardownAsync(); + h.Time.Advance(TimeSpan.FromSeconds(5)); + await Assert.That(h.Chat.ActivityNote).IsEqualTo(""); + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Queued_messages_ignore_old_echoes_and_acknowledge_repeated_text_one_at_a_time() { + await RunOnUiAsync(async () => { + var input = new ScriptedInput(); + var h = new Harness(TranscriptChat.For("claude"), input: input); + var path = Tmp.CreateFile("queued.jsonl", [UserLine]); + await h.PushAsync(Dto(path)); + // Unread history with identical text is older than both sends. + File.AppendAllText(path, UserLine + "\n"); + h.Chat.ComposerText = "hello"; + var send = h.Chat.SendCommand.Execute().ToTask(); + await Assert.That(h.Chat.QueueSummary).IsEqualTo("1 message queued"); + input.Pending!.SetResult(true); + await send; + h.Chat.ComposerText = "hello"; + send = h.Chat.SendCommand.Execute().ToTask(); + input.Pending!.SetResult(true); + await send; + await h.TickAsync(); + await Assert.That(h.Chat.QueueSummary).IsEqualTo("2 messages queued"); + File.AppendAllText(path, UserLine + "\n"); + await h.TickAsync(); + await Assert.That(h.Chat.QueueSummary).IsEqualTo("1 message queued"); + File.AppendAllText(path, UserLine + "\n"); + await h.TickAsync(); + await Assert.That(h.Chat.HasQueuedMessages).IsFalse(); + await h.TeardownAsync(); + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task An_echo_before_the_send_ack_is_not_requeued_and_a_refusal_keeps_the_draft() { + await RunOnUiAsync(async () => { + var input = new ScriptedInput(); + var h = new Harness(TranscriptChat.For("claude"), input: input); + var path = Tmp.CreateFile("queued.jsonl", []); + await h.PushAsync(Dto(path)); + h.Chat.ComposerText = "hello"; + var send = h.Chat.SendCommand.Execute().ToTask(); + File.AppendAllText(path, UserLine + "\n"); + await h.TickAsync(); + await Assert.That(h.Chat.HasQueuedMessages).IsFalse(); + input.Pending!.SetResult(true); + await send; + await Assert.That(h.Chat.HasQueuedMessages).IsFalse(); + h.Chat.ComposerText = "refused"; + send = h.Chat.SendCommand.Execute().ToTask(); + input.Pending!.SetResult(false); + await send; + await Assert.That(h.Chat.HasQueuedMessages).IsFalse(); + await Assert.That(h.Chat.ComposerText).IsEqualTo("refused"); await h.TeardownAsync(); }); } diff --git a/test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs b/test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs index db652168bb..183a9ba822 100644 --- a/test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs +++ b/test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs @@ -384,6 +384,41 @@ await Assert.That(client.SentInput.Select(Encoding.UTF8.GetString)) }); } + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Escape_from_the_composer_sends_a_control_key_and_preserves_the_draft() { + await RunOnUiAsync(async () => { + var host = new Host(); + var client = await host.AttachAsync(Tmp.CreateFile("escape.jsonl", [UserLine])); + host.Type("keep this draft"); + host.Window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None); + host.Settle(); + await Assert.That(client.SentInput).Count().IsEqualTo(1); + await Assert.That(client.SentInput[0]).IsEquivalentTo(new byte[] { 0x1b }); + await Assert.That(host.Composer.Text).IsEqualTo("keep this draft"); + await host.CloseAsync(); + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task The_queue_banner_shows_sent_text_until_the_transcript_echoes_it() { + await RunOnUiAsync(async () => { + var host = new Host(); + var path = Tmp.CreateFile("queue.jsonl", [UserLine]); + await host.AttachAsync(path); + host.Type("queued follow-up"); + host.PressEnter(RawInputModifiers.None); + host.Settle(); + var banner = host.View.FindControl("QueuedMessagesBanner")!; + await Assert.That(banner.IsVisible).IsTrue(); + await Assert.That(banner.GetVisualDescendants().OfType().Any(t => t.Text == "queued follow-up")).IsTrue(); + await host.AppendLinesAndTickAsync(path, UserLine.Replace("hello", "queued follow-up")); + await Assert.That(banner.IsVisible).IsFalse(); + await host.CloseAsync(); + }); + } + /// Pins the other half of the key contract: Shift+Enter stays the TextBox's own newline and /// sends nothing. [Test] diff --git a/test/Capacitor.App.Tests.Unit/RailSessionViewModelTests.cs b/test/Capacitor.App.Tests.Unit/RailSessionViewModelTests.cs index 6fc164b055..3b1af02227 100644 --- a/test/Capacitor.App.Tests.Unit/RailSessionViewModelTests.cs +++ b/test/Capacitor.App.Tests.Unit/RailSessionViewModelTests.cs @@ -95,8 +95,10 @@ await AvaloniaSession.WithImmediateRxScheduler(async () => { using var working = new RailSessionViewModel(Row(awaitingInput: false), new BehaviorSubject(null), NoPending, _ => { }, _ => { }); using var older = new RailSessionViewModel(Row(awaitingInput: null), new BehaviorSubject(null), NoPending, _ => { }, _ => { }); await Assert.That(waiting.NeedsYou).IsTrue(); + await Assert.That(waiting.StatusBadge).IsEqualTo("zzz"); await Assert.That(waiting.Tooltip).Contains("waiting for input"); await Assert.That(working.NeedsYou).IsFalse(); + await Assert.That(working.StatusBadge).IsEqualTo(""); await Assert.That(working.Tooltip).DoesNotContain("waiting for input"); await Assert.That(older.NeedsYou).IsFalse(); }); @@ -171,6 +173,14 @@ await AvaloniaSession.WithImmediateRxScheduler(async () => { using var failed = new RailSessionViewModel(Row(status: "Failed"), new BehaviorSubject(null), pending, _ => { }, _ => { }); await Assert.That(failed.NeedsYou).IsTrue(); + await Assert.That(failed.StatusBadge).IsEqualTo("!"); + + using var idle = new RailSessionViewModel(Row(awaitingInput: true), new BehaviorSubject(null), pending, _ => { }, _ => { }); + await Assert.That(idle.StatusBadge).IsEqualTo("zzz"); + pending.OnNext(new HashSet { "a1" }); + await Assert.That(idle.StatusBadge).IsEqualTo("!"); + pending.OnNext(new HashSet()); + await Assert.That(idle.StatusBadge).IsEqualTo("zzz"); }); } } diff --git a/test/Capacitor.App.Tests.Unit/TerminalChatInputTests.cs b/test/Capacitor.App.Tests.Unit/TerminalChatInputTests.cs index 33d165c1ad..aeda6950ab 100644 --- a/test/Capacitor.App.Tests.Unit/TerminalChatInputTests.cs +++ b/test/Capacitor.App.Tests.Unit/TerminalChatInputTests.cs @@ -64,4 +64,41 @@ await RunOnUiAsync(async () => { await terminal.TeardownAsync(); }); } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Escape_waits_for_submit_and_does_not_paste_or_submit_an_extra_message() { + await RunOnUiAsync(async () => { + var (terminal, input, client, time) = await BuildAttachedAsync(); + await input.SendAsync("follow-up", CancellationToken.None); + await Assert.That(input.CanInterrupt).IsTrue(); + var interrupt = input.InterruptAsync(CancellationToken.None); + await Assert.That(client.SentInput).Count().IsEqualTo(1); + time.Advance(TimeSpan.FromMilliseconds(150)); + await interrupt; + await Assert.That(client.SentInput).Count().IsEqualTo(3); + await Assert.That(client.SentInput[1]).IsEquivalentTo(new byte[] { 0x0d }); + await Assert.That(client.SentInput[2]).IsEquivalentTo(new byte[] { 0x1b }); + input.Dispose(); + await terminal.TeardownAsync(); + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Detaching_during_submit_prevents_the_waiting_escape_from_reaching_the_old_client() { + await RunOnUiAsync(async () => { + var (terminal, input, client, time) = await BuildAttachedAsync(); + await input.SendAsync("follow-up", CancellationToken.None); + var interrupt = input.InterruptAsync(CancellationToken.None); + client.Result.SetResult(new AttachOutcome.Detached()); + await terminal.CurrentRunForTesting!; + time.Advance(TimeSpan.FromMilliseconds(150)); + await interrupt; + await Assert.That(client.SentInput).Count().IsEqualTo(1); + await Assert.That(input.CanInterrupt).IsFalse(); + input.Dispose(); + await terminal.TeardownAsync(); + }); + } } diff --git a/test/Capacitor.Cli.Core.Tests.Unit/JsonlTailTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/JsonlTailTests.cs index c1c23339d8..adaf96fad2 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/JsonlTailTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/JsonlTailTests.cs @@ -28,6 +28,16 @@ public async Task Crlf_is_stripped_and_blank_lines_are_skipped() { var read = new JsonlTail(path).ReadAppended(); await Assert.That(read.Lines).IsEquivalentTo(new[] { "{\"a\":1}", "{\"b\":2}" }); + await Assert.That(read.LineEndOffsets).IsEquivalentTo(new long[] { 9, 23 }); + } + + [Test] + public async Task Line_offsets_count_utf8_bytes_and_remain_absolute_across_reads() { + var path = Tmp.CreateFile("offsets.jsonl", "é\r\n\n"); + var tail = new JsonlTail(path); + await Assert.That(tail.ReadAppended().LineEndOffsets).IsEquivalentTo(new long[] { 4 }); + File.AppendAllText(path, "ok\n"); + await Assert.That(tail.ReadAppended().LineEndOffsets).IsEquivalentTo(new long[] { 8 }); } [Test] From 4ebc1262534baa2cbc4e38e398b9c0d68a9e0859 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 11 Sep 2026 15:09:34 +0200 Subject: [PATCH 2/2] Correct desktop chat delivery and activity tracking --- src/Capacitor.App/ViewModels/ChatInput.cs | 7 +- .../ViewModels/ChatSendOutcome.cs | 3 + .../ViewModels/ChatTabViewModel.cs | 172 ++++++++---- .../ViewModels/LocalFrameChatInput.cs | 27 +- .../ViewModels/QueuedChatMessage.cs | 35 ++- .../ViewModels/TerminalChatInput.cs | 5 +- src/Capacitor.App/Views/ChatTabView.axaml | 10 +- .../ChatProjectionResult.cs | 4 + .../Harness/Claude/ClaudeChatRules.cs | 22 ++ .../IChatTranscriptProjection.cs | 5 + src/Capacitor.Cli.Core/JsonlTail.cs | 12 +- src/Capacitor.Cli.Core/TranscriptChat.cs | 22 +- .../ChatTabViewModelTests.cs | 256 +++++++++++++++++- .../ChatTabViewSmokeTests.cs | 4 + .../LocalFrameChatInputTests.cs | 50 +++- .../TerminalChatInputTests.cs | 4 +- .../Harness/Claude/ClaudeChatRulesTests.cs | 21 ++ .../Harness/Codex/CodexChatRulesTests.cs | 11 + .../JsonlTailTests.cs | 9 +- 19 files changed, 559 insertions(+), 120 deletions(-) create mode 100644 src/Capacitor.App/ViewModels/ChatSendOutcome.cs create mode 100644 src/Capacitor.Cli.Core/ChatProjectionResult.cs diff --git a/src/Capacitor.App/ViewModels/ChatInput.cs b/src/Capacitor.App/ViewModels/ChatInput.cs index 2387bfebba..18e1741e15 100644 --- a/src/Capacitor.App/ViewModels/ChatInput.cs +++ b/src/Capacitor.App/ViewModels/ChatInput.cs @@ -9,8 +9,11 @@ public abstract class ChatInput : ReactiveObject, IDisposable { public abstract SendAvailability Availability { get; } public abstract bool CanAcceptText { get; } public abstract string Hint { get; } - /// Completes when the channel considers the text committed: true to clear the composer. - public abstract Task SendAsync(string text, CancellationToken ct); + /// Acceptance may clear the composer; an unconfirmed delivery must await transcript evidence. + public abstract Task SendAsync(string text, CancellationToken ct); + /// The caller has transcript evidence for its latest send. Older receipts must not clear a + /// newer send's delivery notice. + public virtual void ConfirmLastSend() { } public virtual bool CanInterrupt => false; public virtual Task InterruptAsync(CancellationToken ct) => Task.CompletedTask; public abstract void Dispose(); diff --git a/src/Capacitor.App/ViewModels/ChatSendOutcome.cs b/src/Capacitor.App/ViewModels/ChatSendOutcome.cs new file mode 100644 index 0000000000..5971ff565b --- /dev/null +++ b/src/Capacitor.App/ViewModels/ChatSendOutcome.cs @@ -0,0 +1,3 @@ +namespace Capacitor.App.ViewModels; + +public enum ChatSendOutcome { Rejected, Accepted, Unconfirmed } diff --git a/src/Capacitor.App/ViewModels/ChatTabViewModel.cs b/src/Capacitor.App/ViewModels/ChatTabViewModel.cs index 96109a466d..96ca0024ad 100644 --- a/src/Capacitor.App/ViewModels/ChatTabViewModel.cs +++ b/src/Capacitor.App/ViewModels/ChatTabViewModel.cs @@ -45,6 +45,7 @@ public sealed class ChatTabViewModel : ReactiveObject { readonly CancellationToken _lifetimeToken; readonly AvaloniaList _items = new(); readonly AvaloniaList _queuedMessages = new(); + QueuedChatMessage? _lastSent; readonly Dictionary _pendingTools = new(StringComparer.Ordinal); // Every tool id with a result, not only the running ones: a replayed request can arrive after // the transcript's initial load, and then only this set can tell that its tool is done. @@ -77,6 +78,7 @@ public TranscriptContext ContextFor(IChatTranscriptProjection projection, string } int _generation; + int _inputGeneration; int _readInFlight; string? _path; string? _root; @@ -88,8 +90,17 @@ public TranscriptContext ContextFor(IChatTranscriptProjection projection, string public IAvaloniaReadOnlyList Items => _items; public IAvaloniaReadOnlyList QueuedMessages => _queuedMessages; public bool HasQueuedMessages => _queuedMessages.Count > 0; - public string QueueSummary => $"{_queuedMessages.Count} message{(_queuedMessages.Count == 1 ? "" : "s")} " - + (SessionStatusDots.IsTerminal(_status) ? "unconfirmed" : "queued"); + public string QueueSummary { + get { + var unconfirmed = _queuedMessages.Count(q => q.IsUnconfirmed); + var queued = _queuedMessages.Count - unconfirmed; + return unconfirmed == 0 ? $"{MessageCount(queued)} queued" + : queued == 0 ? $"{MessageCount(unconfirmed)} unconfirmed" + : $"{MessageCount(queued)} queued · {unconfirmed} unconfirmed"; + } + } + + static string MessageCount(int count) => $"{count} message{(count == 1 ? "" : "s")}"; void RefreshQueue() { this.RaisePropertyChanged(nameof(HasQueuedMessages)); @@ -162,15 +173,24 @@ public string ComposerText { string _status = ""; bool? _awaitingInput; long? _workingSince; + TimeSpan _worked; void RefreshActivityNote() { - var working = _status == "Running" && _awaitingInput == false; - if (working) _workingSince ??= _time.GetTimestamp(); - else _workingSince = null; + var inTurn = _status == "Running" && _awaitingInput == false; + var working = inTurn && !HasPendingCards; + if (!inTurn) { + _workingSince = null; + _worked = TimeSpan.Zero; + } else if (working) { + _workingSince ??= _time.GetTimestamp(); + } else if (_workingSince is { } pausedAt) { + _worked += _time.GetElapsedTime(pausedAt); + _workingSince = null; + } ActivityNote = _status == "Starting" ? VendorLabel.Length > 0 ? $"Starting {VendorLabel}…" : "Starting…" - : working && !HasPendingCards && _workingSince is { } since - ? WorkingNote(_time.GetElapsedTime(since)) : ""; + : working && _workingSince is { } since + ? WorkingNote(_worked + _time.GetElapsedTime(since)) : ""; } static string WorkingNote(TimeSpan elapsed) { @@ -324,19 +344,25 @@ public ChatTabViewModel( SendCommand = ReactiveCommand.CreateFromTask(async () => { var snapshot = ComposerText; var edits = _composerEdits; - var queued = new QueuedChatMessage(snapshot, _path); + var queued = new QueuedChatMessage(snapshot, edits, _inputGeneration, TranscriptLength(_path)); + _lastSent = queued; _queuedMessages.Add(queued); RefreshQueue(); - var committed = false; - try { committed = await _input.SendAsync(snapshot, _lifetimeToken); } - catch (OperationCanceledException) { return; } - finally { - // Without a transcript there is no later echo to observe; the channel's ack is - // the only delivery evidence available. Refusals keep the original draft below. - if (!committed || _projection is null) _queuedMessages.Remove(queued); - RefreshQueue(); + ChatSendOutcome outcome; + try { outcome = await _input.SendAsync(snapshot, _lifetimeToken); } + catch (OperationCanceledException) { outcome = ChatSendOutcome.Unconfirmed; } + catch (Exception ex) { + LogOnce($"send: {ex.Message}"); + outcome = ChatSendOutcome.Unconfirmed; } - if (committed && _composerEdits == edits && ComposerText == snapshot) ComposerText = ""; + if (_lifetimeToken.IsCancellationRequested) return; + if (outcome == ChatSendOutcome.Rejected || (outcome == ChatSendOutcome.Accepted && _projection is null)) + _queuedMessages.Remove(queued); + else if (outcome == ChatSendOutcome.Unconfirmed) + queued.MarkUnconfirmed(); + RefreshQueue(); + if (outcome == ChatSendOutcome.Accepted) ClearSentDraft(queued); + if (queued.Acknowledged) ConfirmDelivery(queued); }, canSend); _disposables.Add(SendCommand); @@ -355,6 +381,7 @@ void OnAgentsChanged(IChangeSet changes) { foreach (var change in changes) { if (change.Key == _agentId && change.Reason == ChangeReason.Remove) { _status = "Completed"; + foreach (var queued in _queuedMessages) queued.MarkUnconfirmed(); RefreshActivityNote(); RefreshQueue(); } @@ -379,6 +406,8 @@ void OnDto(AgentStatusDto dto) { StatusText = SessionStatusDots.Label(dto); StatusDot = SessionStatusDots.For(dto.Status); _status = dto.Status; + if (SessionStatusDots.IsTerminal(_status)) + foreach (var queued in _queuedMessages) queued.MarkUnconfirmed(); _awaitingInput = dto.AwaitingInput; if (_projection is not null && dto.TranscriptPath is { } path && path != _path) SwitchPath(path); RefreshActivityNote(); @@ -392,6 +421,7 @@ void SwitchPath(string path) { _openGroup = null; _marked.Clear(); _path = path; + RebaseQueuedMessages(TranscriptLength(path)); _lease = new TailLease(new JsonlTail(path), Interlocked.Increment(ref _generation)); var wasWaiting = _phase == ChatTabPhase.Waiting; Phase = ChatTabPhase.Waiting; @@ -401,6 +431,28 @@ void SwitchPath(string path) { OnTick(); } + static long? TranscriptLength(string? path) { + if (path is null) return null; + try { return new FileInfo(path).Length; } + catch (IOException) { return null; } + catch (UnauthorizedAccessException) { return null; } + } + + void RebaseQueuedMessages(long? offset) { + _inputGeneration++; + foreach (var queued in _queuedMessages) queued.Rebase(_inputGeneration, offset); + RefreshQueue(); + } + + void ClearSentDraft(QueuedChatMessage queued) { + if (_composerEdits == queued.ComposerEdits && ComposerText == queued.Text) ComposerText = ""; + } + + void ConfirmDelivery(QueuedChatMessage queued) { + if (ReferenceEquals(queued, _lastSent)) _input.ConfirmLastSend(); + ClearSentDraft(queued); + } + void OnTick() { if (!Dispatcher.UIThread.CheckAccess()) { Dispatcher.UIThread.Post(OnTick); @@ -418,7 +470,7 @@ async Task ReadAndApplyAsync(TailLease lease, IChatTranscriptProjection projecti var (read, envelopes) = await Task.Run(() => { var result = lease.Tail.ReadAppended(); if (result.Status == TailStatus.Reset) lease.Reset(); - var list = new List<(AcpEventEnvelope Envelope, long Offset)>(); + var list = new List<(ChatProjectionResult Projection, long Offset)>(); if (result.Lines.Count > 0) { var context = lease.ContextFor(projection, _agentId); context.BeginBatch(); @@ -427,8 +479,7 @@ async Task ReadAndApplyAsync(TailLease lease, IChatTranscriptProjection projecti var line = result.Lines[index]; var lineNumber = lease.NextLine(); try { - foreach (var envelope in projection.Project(line, lineNumber, receivedAt, context)) - list.Add((envelope, result.LineEndOffsets[index])); + list.Add((projection.ProjectWithInputs(line, lineNumber, receivedAt, context), result.LineStartOffsets[index])); } catch (Exception ex) { LogOnce($"projection: {ex.Message}"); } } @@ -444,7 +495,7 @@ async Task ReadAndApplyAsync(TailLease lease, IChatTranscriptProjection projecti } } - void Apply(int generation, TailRead read, List<(AcpEventEnvelope Envelope, long Offset)> envelopes) { + void Apply(int generation, TailRead read, List<(ChatProjectionResult Projection, long Offset)> lines) { if (generation != Volatile.Read(ref _generation)) return; switch (read.Status) { @@ -455,6 +506,9 @@ void Apply(int generation, TailRead read, List<(AcpEventEnvelope Envelope, long LogOnce(read.Failure ?? "read failed"); return; case TailStatus.Reset: + // Skip everything already present in the new file, including appends that landed + // while this read was being projected. They may be replayed history, not receipts. + RebaseQueuedMessages(Math.Max(read.SnapshotLength ?? 0, TranscriptLength(_path) ?? 0)); _items.Clear(); _pendingTools.Clear(); _settledTools.Clear(); @@ -464,45 +518,57 @@ void Apply(int generation, TailRead read, List<(AcpEventEnvelope Envelope, long } Phase = ChatTabPhase.Reading; - if (envelopes.Count == 0) { + // A send made before the transcript existed has no safe baseline. Its first successful + // read establishes one; that initial history cannot acknowledge the send. + foreach (var queued in _queuedMessages.Where(q => !q.HasBaseline)) + queued.Rebase(_inputGeneration, Math.Max(read.SnapshotLength ?? 0, TranscriptLength(_path) ?? 0)); + RefreshQueue(); + if (lines.Count == 0) { RefreshActivityNote(); return; } var fresh = new List(); - foreach (var (e, offset) in envelopes) { - switch (e.Kind) { - case AcpEventKind.UserMessage: - var acknowledged = _queuedMessages.FirstOrDefault(q => q.Matches(e.Text ?? "", _path, offset)); - if (acknowledged is not null) _queuedMessages.Remove(acknowledged); - _openGroup = null; - fresh.Add(new UserTurnItem(e.Text ?? "")); - break; - case AcpEventKind.AssistantText: - _openGroup = null; - fresh.Add(new AssistantTextItem(e.Text ?? "")); - break; - case AcpEventKind.SystemNote: - _openGroup = null; - fresh.Add(new SystemNoteItem(e.Text ?? "")); - break; - case AcpEventKind.ToolCall: { - var name = e.ToolName ?? "tool"; - var item = new ToolCallItem(name, ToolDetail.From(e.ToolInputJson, _root), ToolSummary.Categorize(name, e.ToolInputJson)); - if (e.ToolCallId is { } id) _pendingTools[id] = item; - if (_openGroup is null) { - _openGroup = new ToolGroupItem(); - fresh.Add(_openGroup); + foreach (var (projected, offset) in lines) { + foreach (var text in projected.SubmittedInputs) { + var acknowledged = _queuedMessages.FirstOrDefault(q => q.Matches(text, _inputGeneration, offset)); + if (acknowledged is null) continue; + acknowledged.Acknowledged = true; + _queuedMessages.Remove(acknowledged); + ConfirmDelivery(acknowledged); + } + foreach (var e in projected.Envelopes) { + switch (e.Kind) { + case AcpEventKind.UserMessage: + _openGroup = null; + fresh.Add(new UserTurnItem(e.Text ?? "")); + break; + case AcpEventKind.AssistantText: + _openGroup = null; + fresh.Add(new AssistantTextItem(e.Text ?? "")); + break; + case AcpEventKind.SystemNote: + _openGroup = null; + fresh.Add(new SystemNoteItem(e.Text ?? "")); + break; + case AcpEventKind.ToolCall: { + var name = e.ToolName ?? "tool"; + var item = new ToolCallItem(name, ToolDetail.From(e.ToolInputJson, _root), ToolSummary.Categorize(name, e.ToolInputJson)); + if (e.ToolCallId is { } id) _pendingTools[id] = item; + if (_openGroup is null) { + _openGroup = new ToolGroupItem(); + fresh.Add(_openGroup); + } + _openGroup.Add(item); + break; } - _openGroup.Add(item); - break; + case AcpEventKind.ToolResult: + if (e.ToolCallId is not { } resultId) break; + _settledTools.Add(resultId); + if (_pendingTools.Remove(resultId, out var call)) + call.Outcome = e.ToolIsError ? ToolOutcome.Error : ToolOutcome.Done; + break; } - case AcpEventKind.ToolResult: - if (e.ToolCallId is not { } resultId) break; - _settledTools.Add(resultId); - if (_pendingTools.Remove(resultId, out var call)) - call.Outcome = e.ToolIsError ? ToolOutcome.Error : ToolOutcome.Done; - break; } } if (fresh.Count > 0) _items.AddRange(fresh); diff --git a/src/Capacitor.App/ViewModels/LocalFrameChatInput.cs b/src/Capacitor.App/ViewModels/LocalFrameChatInput.cs index bb088b8409..a22af8a4e9 100644 --- a/src/Capacitor.App/ViewModels/LocalFrameChatInput.cs +++ b/src/Capacitor.App/ViewModels/LocalFrameChatInput.cs @@ -62,19 +62,20 @@ void ApplyPresence(AgentPresence p) { _ => "Connecting to the agent…", }; - public override async Task SendAsync(string text, CancellationToken ct) { - if (_disposed || !CanAcceptText) return false; + public override async Task SendAsync(string text, CancellationToken ct) { + if (_disposed || !CanAcceptText || ct.IsCancellationRequested) return ChatSendOutcome.Rejected; _sending = true; _notice = null; Raise(); SendTextResult result; try { result = await _ops.SendTextAsync(_agentId, text, ct); } catch (OperationCanceledException) { - return Settle(false, Unconfirmed); - } catch (Exception ex) { - return Settle(false, ex.Message); + return Settle(ChatSendOutcome.Unconfirmed, Unconfirmed); + } catch (Exception) { + return Settle(ChatSendOutcome.Unconfirmed, Unconfirmed); } - if (result.Ok) return Settle(true, null); - return Settle(false, result.Reason switch { + if (result.Ok) return Settle(ChatSendOutcome.Accepted, null); + var outcome = result.Reason == SendTextReasons.Transport ? ChatSendOutcome.Unconfirmed : ChatSendOutcome.Rejected; + return Settle(outcome, result.Reason switch { SendTextReasons.Transport => Unconfirmed, SendTextReasons.NotRunning => "agent is no longer running", SendTextReasons.NoSuchAgent => "agent is no longer running", @@ -90,10 +91,16 @@ public override async Task SendAsync(string text, CancellationToken ct) { }); } - bool Settle(bool committed, string? notice) { - if (_disposed) return committed; + ChatSendOutcome Settle(ChatSendOutcome outcome, string? notice) { + if (_disposed) return outcome; _sending = false; _notice = notice; Raise(); - return committed; + return outcome; + } + + public override void ConfirmLastSend() { + if (_disposed || _sending || _notice != Unconfirmed) return; + _notice = null; + Raise(); } void Raise() { diff --git a/src/Capacitor.App/ViewModels/QueuedChatMessage.cs b/src/Capacitor.App/ViewModels/QueuedChatMessage.cs index b7807d2b55..175d8ff0f5 100644 --- a/src/Capacitor.App/ViewModels/QueuedChatMessage.cs +++ b/src/Capacitor.App/ViewModels/QueuedChatMessage.cs @@ -1,23 +1,32 @@ +using ReactiveUI.Reactive; + namespace Capacitor.App.ViewModels; /// An outgoing message stays here until the transcript acknowledges it. Transport acceptance /// alone does not mean the runtime has started consuming a prompt queued behind its current turn. -public sealed class QueuedChatMessage { - public string Text { get; } - internal string? TranscriptPath { get; } - internal long TranscriptOffset { get; } +public sealed class QueuedChatMessage(string text, int composerEdits, int generation, long? offset) : ReactiveObject { + public string Text { get; } = text; + internal int ComposerEdits { get; } = composerEdits; + internal bool Acknowledged { get; set; } + int _generation = generation; + long? _offset = offset; + internal bool HasBaseline => _offset.HasValue; + + bool _isUnconfirmed; + public bool IsUnconfirmed { get => _isUnconfirmed; private set => this.RaiseAndSetIfChanged(ref _isUnconfirmed, value); } + + internal void MarkUnconfirmed() => IsUnconfirmed = true; - public QueuedChatMessage(string text, string? transcriptPath) { - Text = text; - TranscriptPath = transcriptPath; - if (transcriptPath is null) return; - try { TranscriptOffset = new FileInfo(transcriptPath).Length; } - catch (IOException) { } - catch (UnauthorizedAccessException) { } + /// A new transcript may contain either replayed history or the lost echo. Neither is safe + /// evidence. Retain the message, then allow only subsequent appends in this generation. + internal void Rebase(int generation, long? offset) { + _generation = generation; + _offset = offset; + MarkUnconfirmed(); } - internal bool Matches(string text, string? path, long offset) => - (TranscriptPath != path || offset > TranscriptOffset) + internal bool Matches(string text, int generation, long offset) => + _generation == generation && _offset is { } baseline && offset >= baseline && Normalize(Text) == Normalize(text); static string Normalize(string text) => text.Replace("\r\n", "\n").Trim(); diff --git a/src/Capacitor.App/ViewModels/TerminalChatInput.cs b/src/Capacitor.App/ViewModels/TerminalChatInput.cs index c15368750a..462f8910ce 100644 --- a/src/Capacitor.App/ViewModels/TerminalChatInput.cs +++ b/src/Capacitor.App/ViewModels/TerminalChatInput.cs @@ -29,8 +29,9 @@ public override Task InterruptAsync(CancellationToken ct) => public override string Hint => HintFor(_terminal.SendAvailability, _terminal.State); /// The terminal path has nothing to cancel: acceptance is synchronous. - public override Task SendAsync(string text, CancellationToken ct) => - Task.FromResult(!_disposed && _terminal.TrySendText(text)); + public override Task SendAsync(string text, CancellationToken ct) => + Task.FromResult(!_disposed && !ct.IsCancellationRequested && _terminal.TrySendText(text) + ? ChatSendOutcome.Accepted : ChatSendOutcome.Rejected); public override void Dispose() { if (_disposed) return; diff --git a/src/Capacitor.App/Views/ChatTabView.axaml b/src/Capacitor.App/Views/ChatTabView.axaml index b00e011221..74dacd62bb 100644 --- a/src/Capacitor.App/Views/ChatTabView.axaml +++ b/src/Capacitor.App/Views/ChatTabView.axaml @@ -370,9 +370,13 @@ - + + + + diff --git a/src/Capacitor.Cli.Core/ChatProjectionResult.cs b/src/Capacitor.Cli.Core/ChatProjectionResult.cs new file mode 100644 index 0000000000..018dfb8ebf --- /dev/null +++ b/src/Capacitor.Cli.Core/ChatProjectionResult.cs @@ -0,0 +1,4 @@ +namespace Capacitor.Cli.Core; + +/// A local slash command can acknowledge input even when its transcript wrappers remain hidden. +public sealed record ChatProjectionResult(IReadOnlyList Envelopes, IReadOnlyList SubmittedInputs); diff --git a/src/Capacitor.Cli.Core/Harness/Claude/ClaudeChatRules.cs b/src/Capacitor.Cli.Core/Harness/Claude/ClaudeChatRules.cs index eba928f74a..bad3eedeb1 100644 --- a/src/Capacitor.Cli.Core/Harness/Claude/ClaudeChatRules.cs +++ b/src/Capacitor.Cli.Core/Harness/Claude/ClaudeChatRules.cs @@ -11,6 +11,22 @@ public sealed partial class ClaudeChatRules : IChatDisplayRules { ClaudeChatRules() { } + public string? SubmittedInput(CanonicalEvent evt, AcpEventEnvelope raw, AcpEventEnvelope? displayed) { + if (raw.Kind != AcpEventKind.UserMessage) return null; + var slug = SchemaExtensions.Slug(evt.Payload, ClaudeCodeExtension.Slug); + if (SchemaExtensions.Flag(slug, ClaudeCodeExtension.IsSidechain) + || SchemaExtensions.Flag(slug, ClaudeCodeExtension.IsMeta) + || SchemaExtensions.Text(slug, ClaudeCodeExtension.OriginKind) == "task-notification") return null; + + var text = raw.Text ?? ""; + var name = CommandName().Match(text).Groups[1].Value.Trim(); + if (name.StartsWith('/')) { + var args = CommandArgs().Match(text).Groups[1].Value.Trim(); + return args.Length == 0 ? name : $"{name} {args}"; + } + return displayed is { Kind: AcpEventKind.UserMessage } user ? user.Text : null; + } + public AcpEventEnvelope? Filter(CanonicalEvent evt, AcpEventEnvelope envelope) { var slug = SchemaExtensions.Slug(evt.Payload, ClaudeCodeExtension.Slug); if (SchemaExtensions.Flag(slug, ClaudeCodeExtension.IsSidechain) || SchemaExtensions.Flag(slug, ClaudeCodeExtension.IsMeta)) return null; @@ -48,6 +64,12 @@ public sealed partial class ClaudeChatRules : IChatDisplayRules { /// echoes. internal static string StripWrappers(string text) => Wrappers().Replace(text, "").Trim(); + [GeneratedRegex(@"(.*?)", RegexOptions.Singleline)] + private static partial Regex CommandName(); + + [GeneratedRegex(@"(.*?)", RegexOptions.Singleline)] + private static partial Regex CommandArgs(); + [GeneratedRegex(@"(.*?)", RegexOptions.Singleline)] private static partial Regex TaskSummary(); diff --git a/src/Capacitor.Cli.Core/IChatTranscriptProjection.cs b/src/Capacitor.Cli.Core/IChatTranscriptProjection.cs index 44990e71bf..d3a6a1c650 100644 --- a/src/Capacitor.Cli.Core/IChatTranscriptProjection.cs +++ b/src/Capacitor.Cli.Core/IChatTranscriptProjection.cs @@ -4,4 +4,9 @@ namespace Capacitor.Cli.Core; public interface IChatTranscriptProjection { TranscriptContext CreateContext(string sessionId, string? agentId); IReadOnlyList Project(string line, int lineNumber, DateTimeOffset receivedAt, TranscriptContext context); + + ChatProjectionResult ProjectWithInputs(string line, int lineNumber, DateTimeOffset receivedAt, TranscriptContext context) { + var envelopes = Project(line, lineNumber, receivedAt, context); + return new(envelopes, envelopes.Where(e => e.Kind == AcpEventKind.UserMessage && e.Text is not null).Select(e => e.Text!).ToArray()); + } } diff --git a/src/Capacitor.Cli.Core/JsonlTail.cs b/src/Capacitor.Cli.Core/JsonlTail.cs index 4b663a18c3..dece411cde 100644 --- a/src/Capacitor.Cli.Core/JsonlTail.cs +++ b/src/Capacitor.Cli.Core/JsonlTail.cs @@ -5,8 +5,10 @@ namespace Capacitor.Cli.Core; public enum TailStatus { Ok, Reset, Missing, Failed } public sealed record TailRead(IReadOnlyList Lines, TailStatus Status, string? Failure = null) { - /// Byte positions immediately after each returned line, for correlating newly appended input. - public IReadOnlyList LineEndOffsets { get; init; } = []; + /// Byte positions at the start of each returned line, for correlating newly appended input. + public IReadOnlyList LineStartOffsets { get; init; } = []; + /// Length observed when this read began, including a partial final line. + public long? SnapshotLength { get; init; } } /// Appended-lines reader over a JSONL file another process is writing. Every open shares @@ -31,7 +33,7 @@ public TailRead ReadAppended() { var status = regressed ? TailStatus.Reset : TailStatus.Ok; if (length == origin) { _cursor = origin; - return new TailRead([], status); + return new TailRead([], status) { SnapshotLength = length }; } stream.Position = origin; @@ -46,7 +48,7 @@ public TailRead ReadAppended() { var offsets = new List(); var lines = SplitCompleteLines(buffer.AsSpan(0, read), out var consumed, offsets, origin); _cursor = origin + consumed; - return new TailRead(lines, status) { LineEndOffsets = offsets }; + return new TailRead(lines, status) { LineStartOffsets = offsets, SnapshotLength = length }; } catch (FileNotFoundException) { return new TailRead([], TailStatus.Missing); } catch (DirectoryNotFoundException) { @@ -71,7 +73,7 @@ static List SplitCompleteLines(ReadOnlySpan bytes, out int consume if (line.Length > 0 && line[^1] == (byte)'\r') line = line[..^1]; if (!IsBlank(line)) { lines.Add(Encoding.UTF8.GetString(line)); - offsets?.Add(origin + i + 1); + offsets?.Add(origin + start); } start = i + 1; consumed = start; diff --git a/src/Capacitor.Cli.Core/TranscriptChat.cs b/src/Capacitor.Cli.Core/TranscriptChat.cs index 1b69a0b73d..c1ff6a9b07 100644 --- a/src/Capacitor.Cli.Core/TranscriptChat.cs +++ b/src/Capacitor.Cli.Core/TranscriptChat.cs @@ -6,20 +6,32 @@ namespace Capacitor.Cli.Core; /// A vendor's say over how its stored events read in the chat: drop one, or rewrite the envelope. public interface IChatDisplayRules { AcpEventEnvelope? Filter(CanonicalEvent evt, AcpEventEnvelope envelope); + + /// Most receipts are visible user turns. Vendors may also record hidden command receipts; + /// injected prompts and background notifications must never acknowledge submitted input. + string? SubmittedInput(CanonicalEvent evt, AcpEventEnvelope raw, AcpEventEnvelope? displayed) => + displayed is { Kind: AcpEventKind.UserMessage } user ? user.Text : null; } /// The chat's view of a transcript: the leaf projection, the envelope mapping, one vendor's rules. public sealed class TranscriptChatProjection(ITranscriptProjection projection, IChatDisplayRules rules) : IChatTranscriptProjection { public TranscriptContext CreateContext(string sessionId, string? agentId) => projection.CreateContext(sessionId, agentId); - public IReadOnlyList Project(string line, int lineNumber, DateTimeOffset receivedAt, TranscriptContext context) { + public IReadOnlyList Project(string line, int lineNumber, DateTimeOffset receivedAt, TranscriptContext context) => + ProjectWithInputs(line, lineNumber, receivedAt, context).Envelopes; + + public ChatProjectionResult ProjectWithInputs(string line, int lineNumber, DateTimeOffset receivedAt, TranscriptContext context) { var result = projection.Project(line, lineNumber, receivedAt, context); - if (result.Events.Count == 0) return []; + if (result.Events.Count == 0) return new([], []); var shown = new List(result.Events.Count); + var submitted = new List(); foreach (var evt in result.Events) - foreach (var envelope in TranscriptEnvelopes.From(evt)) - if (rules.Filter(evt, envelope) is { } kept) shown.Add(kept); - return shown; + foreach (var envelope in TranscriptEnvelopes.From(evt)) { + var kept = rules.Filter(evt, envelope); + if (kept is { } visible) shown.Add(visible); + if (rules.SubmittedInput(evt, envelope, kept) is { Length: > 0 } text) submitted.Add(text); + } + return new(shown, submitted); } } diff --git a/test/Capacitor.App.Tests.Unit/ChatTabViewModelTests.cs b/test/Capacitor.App.Tests.Unit/ChatTabViewModelTests.cs index 0afd35b87e..bb797850aa 100644 --- a/test/Capacitor.App.Tests.Unit/ChatTabViewModelTests.cs +++ b/test/Capacitor.App.Tests.Unit/ChatTabViewModelTests.cs @@ -673,15 +673,15 @@ await RunOnUiAsync(async () => { /// A scripted ChatInput for composer tests: SendAsync completes when the test says so. sealed class ScriptedInput : ChatInput { - public TaskCompletionSource? Pending; + public TaskCompletionSource? Pending; public int Disposals; public List<(string Text, CancellationToken Ct)> Sends { get; } = []; public override SendAvailability Availability => Pending is null ? SendAvailability.Ready : SendAvailability.Sending; public override bool CanAcceptText => Pending is null; public override string Hint => "scripted"; - public override Task SendAsync(string text, CancellationToken ct) { + public override Task SendAsync(string text, CancellationToken ct) { Sends.Add((text, ct)); - Pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); this.RaisePropertyChanged(nameof(CanAcceptText)); return Pending.Task.ContinueWith(t => { Pending = null; this.RaisePropertyChanged(nameof(CanAcceptText)); return t.Result; }, ct, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } @@ -738,19 +738,19 @@ await RunOnUiAsync(async () => { var send = h.Chat.SendCommand.Execute().ToTask(); await Assert.That(input.Sends.Single().Text).IsEqualTo("hello"); h.Chat.ComposerText = "hello edited"; - input.Pending!.SetResult(true); + input.Pending!.SetResult(ChatSendOutcome.Accepted); await send; await Assert.That(h.Chat.ComposerText).IsEqualTo("hello edited"); h.Chat.ComposerText = "two"; send = h.Chat.SendCommand.Execute().ToTask(); - input.Pending!.SetResult(false); + input.Pending!.SetResult(ChatSendOutcome.Rejected); await send; await Assert.That(h.Chat.ComposerText).IsEqualTo("two"); h.Chat.ComposerText = "three"; send = h.Chat.SendCommand.Execute().ToTask(); - input.Pending!.SetResult(true); + input.Pending!.SetResult(ChatSendOutcome.Accepted); await send; await Assert.That(h.Chat.ComposerText).IsEqualTo(""); await h.TeardownAsync(); @@ -771,7 +771,7 @@ await RunOnUiAsync(async () => { var send = h.Chat.SendCommand.Execute().ToTask(); h.Chat.ComposerText = "hello!"; h.Chat.ComposerText = "hello"; - input.Pending!.SetResult(true); + input.Pending!.SetResult(ChatSendOutcome.Accepted); await send; await Assert.That(h.Chat.ComposerText).IsEqualTo("hello"); @@ -922,11 +922,11 @@ await RunOnUiAsync(async () => { h.Chat.ComposerText = "hello"; var send = h.Chat.SendCommand.Execute().ToTask(); await Assert.That(h.Chat.QueueSummary).IsEqualTo("1 message queued"); - input.Pending!.SetResult(true); + input.Pending!.SetResult(ChatSendOutcome.Accepted); await send; h.Chat.ComposerText = "hello"; send = h.Chat.SendCommand.Execute().ToTask(); - input.Pending!.SetResult(true); + input.Pending!.SetResult(ChatSendOutcome.Accepted); await send; await h.TickAsync(); await Assert.That(h.Chat.QueueSummary).IsEqualTo("2 messages queued"); @@ -953,12 +953,12 @@ await RunOnUiAsync(async () => { File.AppendAllText(path, UserLine + "\n"); await h.TickAsync(); await Assert.That(h.Chat.HasQueuedMessages).IsFalse(); - input.Pending!.SetResult(true); + input.Pending!.SetResult(ChatSendOutcome.Accepted); await send; await Assert.That(h.Chat.HasQueuedMessages).IsFalse(); h.Chat.ComposerText = "refused"; send = h.Chat.SendCommand.Execute().ToTask(); - input.Pending!.SetResult(false); + input.Pending!.SetResult(ChatSendOutcome.Rejected); await send; await Assert.That(h.Chat.HasQueuedMessages).IsFalse(); await Assert.That(h.Chat.ComposerText).IsEqualTo("refused"); @@ -966,6 +966,240 @@ await RunOnUiAsync(async () => { }); } + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_path_switch_keeps_unconfirmed_input_until_a_fresh_echo() { + await RunOnUiAsync(async () => { + var input = new ScriptedInput(); + var h = new Harness(TranscriptChat.For("claude"), input: input); + try { + var path = Tmp.CreateFile("before.jsonl", [UserLine]); + await h.PushAsync(Dto(path)); + h.Chat.ComposerText = "hello"; + var send = h.Chat.SendCommand.Execute().ToTask(); + input.Pending!.SetResult(ChatSendOutcome.Accepted); + await send; + var other = Tmp.CreateFile("after.jsonl", [UserLine]); + await h.PushAsync(Dto(other)); + await Assert.That(h.Chat.QueueSummary).IsEqualTo("1 message unconfirmed"); + File.AppendAllText(other, UserLine + "\n"); + await h.TickAsync(); + await Assert.That(h.Chat.HasQueuedMessages).IsFalse(); + } finally { await h.TeardownAsync(); } + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_reset_rebases_input_without_acknowledging_replayed_history() { + await RunOnUiAsync(async () => { + var input = new ScriptedInput(); + var h = new Harness(TranscriptChat.For("claude"), input: input); + try { + var path = Tmp.CreateFile("reset.jsonl", [UserLine, AssistantLine, AssistantLine]); + await h.PushAsync(Dto(path)); + h.Chat.ComposerText = "hello"; + var send = h.Chat.SendCommand.Execute().ToTask(); + input.Pending!.SetResult(ChatSendOutcome.Accepted); + await send; + File.WriteAllLines(path, [UserLine]); + await h.TickAsync(); + await Assert.That(h.Chat.QueueSummary).IsEqualTo("1 message unconfirmed"); + File.AppendAllText(path, UserLine + "\n"); + await h.TickAsync(); + await Assert.That(h.Chat.HasQueuedMessages).IsFalse(); + } finally { await h.TeardownAsync(); } + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task The_working_clock_excludes_time_blocked_on_a_card() { + await RunOnUiAsync(async () => { + var h = new Harness(TranscriptChat.Journal); + try { + await h.PushAsync(Agent("a1", "pi", hasTerminal: false) with { Status = "Running", AwaitingInput = false }); + h.Time.Advance(TimeSpan.FromSeconds(65)); + h.Permissions.Add(PermissionEntries.Entry("r1", "a1")); + await WaitUntilAsync(() => h.Chat.HasPendingCards, what: "the blocking card"); + h.Time.Advance(TimeSpan.FromMinutes(10)); + await Assert.That(h.Chat.ActivityNote).IsEqualTo(""); + h.Permissions.Remove("r1"); + await WaitUntilAsync(() => !h.Chat.HasPendingCards, what: "the card removed"); + await Assert.That(h.Chat.ActivityNote).IsEqualTo("Working for 1m 5s"); + h.Time.Advance(TimeSpan.FromSeconds(2)); + await Assert.That(h.Chat.ActivityNote).IsEqualTo("Working for 1m 7s"); + } finally { await h.TeardownAsync(); } + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + [Arguments(false)] + [Arguments(true)] + public async Task A_lost_ack_keeps_input_unconfirmed_until_a_transcript_echo(bool newerSend) { + await RunOnUiAsync(async () => { + var daemon = new FakeDaemonClientService(); + using var presence = new System.Reactive.Subjects.BehaviorSubject( + new AgentPresence(Agent("a1", "pi", hasTerminal: false) with { Status = "Running" }, false)); + var ops = new ScriptedLocalControlOps(); + var input = new LocalFrameChatInput("a1", daemon, ops, presence); + var h = new Harness(TranscriptChat.Journal, input: input); + try { + daemon.StatusSubject.OnNext(new AttachStatus(AttachState.Connected, null, ["input/1"])); + var path = Tmp.CreateFile("lost-ack.jsonl", []); + await h.PushAsync(Hosted(path, "Running", false)); + ops.QueueSendText(new SendTextResult(false, SendTextReasons.Transport, "lost ack", null)); + h.Chat.ComposerText = "hello"; + await h.Chat.SendCommand.Execute(); + await Assert.That(h.Chat.QueueSummary).IsEqualTo("1 message unconfirmed"); + await Assert.That(h.Chat.ComposerText).IsEqualTo("hello"); + if (newerSend) { + ops.QueueSendText(new SendTextResult(false, SendTextReasons.Transport, "another lost ack", null)); + h.Chat.ComposerText = "later"; + await h.Chat.SendCommand.Execute(); + } + File.AppendAllText(path, EnvelopeJournalFormat.Write(new AcpEventEnvelope(Kind: AcpEventKind.UserMessage, Text: "hello")) + "\n"); + await h.TickAsync(); + if (newerSend) { + await Assert.That(h.Chat.QueueSummary).IsEqualTo("1 message unconfirmed"); + await Assert.That(h.Chat.ComposerHint).IsEqualTo("delivery unconfirmed — check the chat before sending again"); + await Assert.That(h.Chat.ComposerText).IsEqualTo("later"); + File.AppendAllText(path, EnvelopeJournalFormat.Write(new AcpEventEnvelope(Kind: AcpEventKind.UserMessage, Text: "later")) + "\n"); + await h.TickAsync(); + } + await Assert.That(h.Chat.HasQueuedMessages).IsFalse(); + await Assert.That(h.Chat.ComposerText).IsEqualTo(""); + await Assert.That(h.Chat.ComposerHint).IsEqualTo("Enter sends · Shift+Enter for a new line"); + } finally { await h.TeardownAsync(); } + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_slash_command_echo_acknowledges_input_without_a_display_row() { + await RunOnUiAsync(async () => { + var input = new ScriptedInput(); + var h = new Harness(TranscriptChat.For("claude"), input: input); + try { + var path = Tmp.CreateFile("slash.jsonl", []); + await h.PushAsync(Dto(path)); + h.Chat.ComposerText = "/clear"; + var send = h.Chat.SendCommand.Execute().ToTask(); + input.Pending!.SetResult(ChatSendOutcome.Accepted); + await send; + File.AppendAllText(path, """{"type":"user","message":{"content":"/clearok"}}""" + "\n"); + await h.TickAsync(); + await Assert.That(h.Chat.HasQueuedMessages).IsFalse(); + await Assert.That(h.Chat.Items).IsEmpty(); + } finally { await h.TeardownAsync(); } + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + [Arguments(false, false)] + [Arguments(false, true)] + [Arguments(true, false)] + [Arguments(true, true)] + public async Task Unconfirmed_send_and_echo_can_arrive_in_either_order_without_erasing_a_new_draft(bool echoFirst, bool editBack) { + await RunOnUiAsync(async () => { + var input = new ScriptedInput(); + var h = new Harness(TranscriptChat.For("claude"), input: input); + try { + var path = Tmp.CreateFile("uncertain.jsonl", []); + await h.PushAsync(Dto(path)); + h.Chat.ComposerText = "hello"; + var send = h.Chat.SendCommand.Execute().ToTask(); + h.Chat.ComposerText = "another draft"; + if (editBack) h.Chat.ComposerText = "hello"; + if (echoFirst) { + File.AppendAllText(path, UserLine + "\n"); + await h.TickAsync(); + } + input.Pending!.SetResult(ChatSendOutcome.Unconfirmed); + await send; + if (!echoFirst) { + await Assert.That(h.Chat.QueuedMessages.Single().IsUnconfirmed).IsTrue(); + File.AppendAllText(path, UserLine + "\n"); + await h.TickAsync(); + } + await Assert.That(h.Chat.HasQueuedMessages).IsFalse(); + await Assert.That(h.Chat.ComposerText).IsEqualTo(editBack ? "hello" : "another draft"); + } finally { await h.TeardownAsync(); } + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + [Arguments(ChatSendOutcome.Accepted, false)] + [Arguments(ChatSendOutcome.Rejected, false)] + [Arguments(ChatSendOutcome.Unconfirmed, true)] + public async Task Without_a_projection_only_uncertain_delivery_stays_in_the_queue(ChatSendOutcome outcome, bool queued) { + await RunOnUiAsync(async () => { + var input = new ScriptedInput(); + var h = new Harness(null, input: input); + try { + h.Chat.ComposerText = "hello"; + var send = h.Chat.SendCommand.Execute().ToTask(); + input.Pending!.SetResult(outcome); + await send; + await Assert.That(h.Chat.HasQueuedMessages).IsEqualTo(queued); + await Assert.That(h.Chat.ComposerText).IsEqualTo(outcome == ChatSendOutcome.Accepted ? "" : "hello"); + } finally { await h.TeardownAsync(); } + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + [Arguments(false)] + [Arguments(true)] + public async Task Initial_history_and_preexisting_partial_lines_cannot_acknowledge_a_send(bool missing) { + await RunOnUiAsync(async () => { + var input = new ScriptedInput(); + var h = new Harness(TranscriptChat.For("claude"), input: input); + try { + var path = missing ? Tmp.PathTo("missing.jsonl") : Tmp.CreateFile("partial.jsonl", UserLine); + await h.PushAsync(Dto(path)); + h.Chat.ComposerText = "hello"; + var send = h.Chat.SendCommand.Execute().ToTask(); + input.Pending!.SetResult(ChatSendOutcome.Accepted); + await send; + if (missing) File.WriteAllText(path, UserLine + "\n"); + else File.AppendAllText(path, "\n"); + await h.TickAsync(); + await Assert.That(h.Chat.QueuedMessages).Count().IsEqualTo(1); + File.AppendAllText(path, UserLine + "\n"); + await h.TickAsync(); + await Assert.That(h.Chat.HasQueuedMessages).IsFalse(); + } finally { await h.TeardownAsync(); } + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Mixed_queue_distinguishes_unconfirmed_and_accepted_sends_and_marks_both_at_session_end() { + await RunOnUiAsync(async () => { + var input = new ScriptedInput(); + var h = new Harness(TranscriptChat.Journal, input: input); + try { + h.Chat.ComposerText = "hello"; + var send = h.Chat.SendCommand.Execute().ToTask(); + input.Pending!.SetResult(ChatSendOutcome.Unconfirmed); + await send; + h.Chat.ComposerText = "next"; + send = h.Chat.SendCommand.Execute().ToTask(); + input.Pending!.SetResult(ChatSendOutcome.Accepted); + await send; + await Assert.That(h.Chat.QueueSummary).IsEqualTo("1 message queued · 1 unconfirmed"); + await Assert.That(h.Chat.QueuedMessages[0].IsUnconfirmed).IsTrue(); + await Assert.That(h.Chat.QueuedMessages[1].IsUnconfirmed).IsFalse(); + await h.PushAsync(Agent("a1", "pi", hasTerminal: false) with { Status = "Completed" }); + await Assert.That(h.Chat.QueueSummary).IsEqualTo("2 messages unconfirmed"); + } finally { await h.TeardownAsync(); } + }); + } + sealed class CountingProjection(ITranscriptProjection inner) : ITranscriptProjection { public List LineNumbers { get; } = []; public int ContextsCreated { get; private set; } diff --git a/test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs b/test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs index 183a9ba822..0019f6cff5 100644 --- a/test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs +++ b/test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs @@ -413,6 +413,10 @@ await RunOnUiAsync(async () => { var banner = host.View.FindControl("QueuedMessagesBanner")!; await Assert.That(banner.IsVisible).IsTrue(); await Assert.That(banner.GetVisualDescendants().OfType().Any(t => t.Text == "queued follow-up")).IsTrue(); + await Assert.That(banner.GetVisualDescendants().OfType().Any(t => t.Text == "Delivery unconfirmed" && t.IsVisible)).IsFalse(); + host.Daemon.Agents.AddOrUpdate(Agent("a1", "claude", hasTerminal: true) with { TranscriptPath = path, Status = "Completed" }); + host.Settle(); + await Assert.That(banner.GetVisualDescendants().OfType().Any(t => t.Text == "Delivery unconfirmed" && t.IsVisible)).IsTrue(); await host.AppendLinesAndTickAsync(path, UserLine.Replace("hello", "queued follow-up")); await Assert.That(banner.IsVisible).IsFalse(); await host.CloseAsync(); diff --git a/test/Capacitor.App.Tests.Unit/LocalFrameChatInputTests.cs b/test/Capacitor.App.Tests.Unit/LocalFrameChatInputTests.cs index 6c1c75f8c5..25815829b7 100644 --- a/test/Capacitor.App.Tests.Unit/LocalFrameChatInputTests.cs +++ b/test/Capacitor.App.Tests.Unit/LocalFrameChatInputTests.cs @@ -33,7 +33,7 @@ await RunOnUiAsync(async () => { rig.Running(); await Assert.That(rig.Input.Availability).IsEqualTo(SendAvailability.Unsupported); await Assert.That(rig.Input.Hint).IsEqualTo("Update the daemon to send messages from the app"); - await Assert.That(await rig.Input.SendAsync("x", CancellationToken.None)).IsFalse(); + await Assert.That(await rig.Input.SendAsync("x", CancellationToken.None)).IsEqualTo(ChatSendOutcome.Rejected); await Assert.That(rig.Ops.SendTextCalls).IsEqualTo(0); rig.Connected("status/1", "input/1"); @@ -56,9 +56,9 @@ await RunOnUiAsync(async () => { var pending = rig.Input.SendAsync("hello", CancellationToken.None); await Assert.That(rig.Input.Availability).IsEqualTo(SendAvailability.Sending); await Assert.That(rig.Input.CanAcceptText).IsFalse(); - await Assert.That(await rig.Input.SendAsync("second", CancellationToken.None)).IsFalse(); + await Assert.That(await rig.Input.SendAsync("second", CancellationToken.None)).IsEqualTo(ChatSendOutcome.Rejected); gate.SetResult(new SendTextResult(true, null, null, SendTextOutcomes.Delivered)); - await Assert.That(await pending).IsTrue(); + await Assert.That(await pending).IsEqualTo(ChatSendOutcome.Accepted); await Assert.That(rig.Input.Availability).IsEqualTo(SendAvailability.Ready); await Assert.That(rig.Ops.SendTextPayloads).IsEquivalentTo(new[] { ("a1", "hello") }); }); @@ -80,7 +80,8 @@ public async Task Refusal_keeps_the_text_and_words_the_hint(string reason, strin await RunOnUiAsync(async () => { var rig = new Rig(); rig.Connected("input/1"); rig.Running(); rig.Ops.QueueSendText(new SendTextResult(false, reason, error, null)); - await Assert.That(await rig.Input.SendAsync("hello", CancellationToken.None)).IsFalse(); + await Assert.That(await rig.Input.SendAsync("hello", CancellationToken.None)).IsEqualTo( + reason == SendTextReasons.Transport ? ChatSendOutcome.Unconfirmed : ChatSendOutcome.Rejected); await Assert.That(rig.Input.Hint).IsEqualTo(hint); await Assert.That(rig.Input.Availability).IsEqualTo(SendAvailability.Ready); rig.Ops.ArmSendText(); @@ -95,9 +96,9 @@ public async Task Stopped_outcome_clears_and_unknown_ok_outcome_counts_as_delive await RunOnUiAsync(async () => { var rig = new Rig(); rig.Connected("input/1"); rig.Running(); rig.Ops.QueueSendText(new SendTextResult(true, null, null, SendTextOutcomes.Stopped)); - await Assert.That(await rig.Input.SendAsync("/quit", CancellationToken.None)).IsTrue(); + await Assert.That(await rig.Input.SendAsync("/quit", CancellationToken.None)).IsEqualTo(ChatSendOutcome.Accepted); rig.Ops.QueueSendText(new SendTextResult(true, null, null, "future_outcome")); - await Assert.That(await rig.Input.SendAsync("x", CancellationToken.None)).IsTrue(); + await Assert.That(await rig.Input.SendAsync("x", CancellationToken.None)).IsEqualTo(ChatSendOutcome.Accepted); }); } @@ -110,7 +111,7 @@ await RunOnUiAsync(async () => { using var cts = new CancellationTokenSource(); var pending = rig.Input.SendAsync("hello", cts.Token); await cts.CancelAsync(); - await Assert.That(await pending).IsFalse(); + await Assert.That(await pending).IsEqualTo(ChatSendOutcome.Unconfirmed); await Assert.That(rig.Input.Hint).IsEqualTo("delivery unconfirmed — check the chat before sending again"); await Assert.That(rig.Input.Availability).IsEqualTo(SendAvailability.Ready); }); @@ -122,7 +123,7 @@ public async Task Session_ending_after_a_refusal_clears_the_notice() { await RunOnUiAsync(async () => { var rig = new Rig(); rig.Connected("input/1"); rig.Running(); rig.Ops.QueueSendText(new SendTextResult(false, "queue_full", null, null)); - await Assert.That(await rig.Input.SendAsync("hello", CancellationToken.None)).IsFalse(); + await Assert.That(await rig.Input.SendAsync("hello", CancellationToken.None)).IsEqualTo(ChatSendOutcome.Rejected); await Assert.That(rig.Input.Hint).IsEqualTo("the agent's input queue is full, try again shortly"); rig.Presence.OnNext(new AgentPresence(rig.Presence.Value.Dto, true)); @@ -131,13 +132,40 @@ await RunOnUiAsync(async () => { }); } + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Cancellation_before_send_does_not_attempt_delivery() { + await RunOnUiAsync(async () => { + var rig = new Rig(); rig.Connected("input/1"); rig.Running(); + await Assert.That(await rig.Input.SendAsync("hello", new CancellationToken(true))).IsEqualTo(ChatSendOutcome.Rejected); + await Assert.That(rig.Ops.SendTextCalls).IsEqualTo(0); + rig.Input.Dispose(); + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_transport_exception_is_unconfirmed_until_delivery_is_confirmed() { + await RunOnUiAsync(async () => { + var rig = new Rig(); rig.Connected("input/1"); rig.Running(); + var gate = rig.Ops.ArmSendText(); + var send = rig.Input.SendAsync("hello", CancellationToken.None); + gate.SetException(new IOException("connection closed")); + await Assert.That(await send).IsEqualTo(ChatSendOutcome.Unconfirmed); + await Assert.That(rig.Input.Hint).IsEqualTo("delivery unconfirmed — check the chat before sending again"); + rig.Input.ConfirmLastSend(); + await Assert.That(rig.Input.Hint).IsEqualTo("Enter sends · Shift+Enter for a new line"); + rig.Input.Dispose(); + }); + } + [Test] [NotInParallel("AvaloniaSession")] public async Task A_status_reemission_that_does_not_change_availability_keeps_the_notice() { await RunOnUiAsync(async () => { var rig = new Rig(); rig.Connected("input/1"); rig.Running(); rig.Ops.QueueSendText(new SendTextResult(false, "queue_full", null, null)); - await Assert.That(await rig.Input.SendAsync("hello", CancellationToken.None)).IsFalse(); + await Assert.That(await rig.Input.SendAsync("hello", CancellationToken.None)).IsEqualTo(ChatSendOutcome.Rejected); await Assert.That(rig.Input.Hint).IsEqualTo("the agent's input queue is full, try again shortly"); rig.Connected("input/1"); @@ -157,7 +185,7 @@ await RunOnUiAsync(async () => { var raised = 0; rig.Input.PropertyChanged += (_, _) => raised++; gate.SetResult(new SendTextResult(false, "queue_full", null, null)); - await Assert.That(await pending).IsFalse(); + await Assert.That(await pending).IsEqualTo(ChatSendOutcome.Rejected); rig.Connected("input/1"); rig.Running(); rig.Presence.OnNext(new AgentPresence(null, true)); await Assert.That(raised).IsEqualTo(0); @@ -174,7 +202,7 @@ await RunOnUiAsync(async () => { rig.Input.Dispose(); await Assert.That(rig.Input.CanAcceptText).IsTrue(); - await Assert.That(await rig.Input.SendAsync("after", CancellationToken.None)).IsFalse(); + await Assert.That(await rig.Input.SendAsync("after", CancellationToken.None)).IsEqualTo(ChatSendOutcome.Rejected); await Assert.That(rig.Ops.SendTextCalls).IsEqualTo(0); }); } diff --git a/test/Capacitor.App.Tests.Unit/TerminalChatInputTests.cs b/test/Capacitor.App.Tests.Unit/TerminalChatInputTests.cs index aeda6950ab..35ccb0521d 100644 --- a/test/Capacitor.App.Tests.Unit/TerminalChatInputTests.cs +++ b/test/Capacitor.App.Tests.Unit/TerminalChatInputTests.cs @@ -35,7 +35,7 @@ await RunOnUiAsync(async () => { var raised = new List(); input.PropertyChanged += (_, e) => raised.Add(e.PropertyName!); - await Assert.That(await input.SendAsync("hello", CancellationToken.None)).IsTrue(); + await Assert.That(await input.SendAsync("hello", CancellationToken.None)).IsEqualTo(ChatSendOutcome.Accepted); await Assert.That(input.Availability).IsEqualTo(SendAvailability.Sending); await Assert.That(input.Hint).IsEqualTo("Sending…"); await Assert.That(raised).Contains(nameof(ChatInput.Availability)); @@ -60,7 +60,7 @@ await RunOnUiAsync(async () => { client.Result.SetResult(new AttachOutcome.Exited(0)); await terminal.CurrentRunForTesting!; await Assert.That(raised).IsEqualTo(0); - await Assert.That(await input.SendAsync("x", CancellationToken.None)).IsFalse(); + await Assert.That(await input.SendAsync("x", CancellationToken.None)).IsEqualTo(ChatSendOutcome.Rejected); await terminal.TeardownAsync(); }); } diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Claude/ClaudeChatRulesTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Claude/ClaudeChatRulesTests.cs index 788071a30f..4224751411 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Claude/ClaudeChatRulesTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Claude/ClaudeChatRulesTests.cs @@ -9,6 +9,27 @@ static IReadOnlyList P(string line) { return chat.Project(line, 1, Received, chat.CreateContext("a1", null)); } + [Test] + [Arguments("")] + [Arguments(" inspect the queue ")] + public async Task Hidden_slash_commands_acknowledge_the_submitted_command_and_arguments(string args) { + var chat = TranscriptChat.For("claude")!; + var result = chat.ProjectWithInputs($$$"""{"type":"user","message":{"content":"/reviewreview{{{args}}}"}}""", 1, Received, chat.CreateContext("a1", null)); + await Assert.That(result.Envelopes).IsEmpty(); + await Assert.That(result.SubmittedInputs).IsEquivalentTo(new[] { args.Trim().Length == 0 ? "/review" : $"/review {args.Trim()}" }); + } + + [Test] + [Arguments("\"isMeta\":true")] + [Arguments("\"isSidechain\":true")] + [Arguments("\"origin\":{\"kind\":\"task-notification\"}")] + public async Task Injected_command_wrappers_never_acknowledge_user_input(string flags) { + var chat = TranscriptChat.For("claude")!; + var result = chat.ProjectWithInputs($$$"""{"type":"user",{{{flags}}},"message":{"content":"/clearok"}}""", 1, Received, chat.CreateContext("a1", null)); + await Assert.That(result.SubmittedInputs).IsEmpty(); + await Assert.That(result.Envelopes.Any(e => e.Kind == AcpEventKind.UserMessage)).IsFalse(); + } + [Test] public async Task String_user_content_is_one_user_message_with_its_timestamp() { var e = P("""{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"2026-08-26T12:00:00Z"}"""); diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexChatRulesTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexChatRulesTests.cs index 562295db8b..5a58553a28 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexChatRulesTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexChatRulesTests.cs @@ -8,6 +8,17 @@ static IReadOnlyList P(string line) { return chat.Project(line, 1, Received, chat.CreateContext("a1", null)); } + [Test] + [Arguments("hello", true)] + [Arguments("", false)] + [Arguments("# AGENTS.md instructions", false)] + public async Task Only_visible_human_prompts_acknowledge_submitted_input(string text, bool human) { + var chat = TranscriptChat.For("codex")!; + var result = chat.ProjectWithInputs(Item($$"""{"type":"message","role":"user","content":[{"type":"input_text","text":"{{text}}"}]}"""), 1, Received, chat.CreateContext("a1", null)); + await Assert.That(result.SubmittedInputs).IsEquivalentTo(human ? new[] { text } : Array.Empty()); + await Assert.That(result.Envelopes.Count).IsEqualTo(human ? 1 : 0); + } + static string Item(string payload, string ts = "2026-08-25T00:00:00Z") => $$$"""{"timestamp":"{{{ts}}}","ordinal":1,"type":"response_item","payload":{{{payload}}}}"""; diff --git a/test/Capacitor.Cli.Core.Tests.Unit/JsonlTailTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/JsonlTailTests.cs index adaf96fad2..197c8371ed 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/JsonlTailTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/JsonlTailTests.cs @@ -13,9 +13,12 @@ public async Task Complete_lines_are_delivered_once_and_a_partial_final_line_is_ var first = tail.ReadAppended(); await Assert.That(first.Status).IsEqualTo(TailStatus.Ok); await Assert.That(first.Lines).IsEquivalentTo(new[] { "{\"a\":1}", "{\"b\":2}" }); + await Assert.That(first.SnapshotLength).IsEqualTo(new FileInfo(path).Length); + await Assert.That(first.SnapshotLength!.Value).IsGreaterThan(tail.Cursor); var second = tail.ReadAppended(); await Assert.That(second.Lines).IsEmpty(); + await Assert.That(second.SnapshotLength).IsEqualTo(first.SnapshotLength); File.AppendAllText(path, "3}\n"); var third = tail.ReadAppended(); @@ -28,16 +31,16 @@ public async Task Crlf_is_stripped_and_blank_lines_are_skipped() { var read = new JsonlTail(path).ReadAppended(); await Assert.That(read.Lines).IsEquivalentTo(new[] { "{\"a\":1}", "{\"b\":2}" }); - await Assert.That(read.LineEndOffsets).IsEquivalentTo(new long[] { 9, 23 }); + await Assert.That(read.LineStartOffsets).IsEquivalentTo(new long[] { 0, 14 }); } [Test] public async Task Line_offsets_count_utf8_bytes_and_remain_absolute_across_reads() { var path = Tmp.CreateFile("offsets.jsonl", "é\r\n\n"); var tail = new JsonlTail(path); - await Assert.That(tail.ReadAppended().LineEndOffsets).IsEquivalentTo(new long[] { 4 }); + await Assert.That(tail.ReadAppended().LineStartOffsets).IsEquivalentTo(new long[] { 0 }); File.AppendAllText(path, "ok\n"); - await Assert.That(tail.ReadAppended().LineEndOffsets).IsEquivalentTo(new long[] { 8 }); + await Assert.That(tail.ReadAppended().LineStartOffsets).IsEquivalentTo(new long[] { 5 }); } [Test]