From d9d315273475be1ce8e008399e4361c38aaf87d9 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Tue, 8 Sep 2026 15:56:35 +0200 Subject: [PATCH 1/2] Close an import batch on bytes and report what it drops (#814) A stderr line written under the Spectre live region is erased by its next frame, so the warnings ride the progress channel and carry their session id for the routed sources' shared sink. Co-Authored-By: Claude Fable 5.1 --- docs/CHANGES.md | 14 ++ src/Capacitor.Cli/Commands/IImportSource.cs | 12 +- src/Capacitor.Cli/Commands/ImportCommand.cs | 15 +- src/Capacitor.Cli/Commands/ImportProgress.cs | 24 ++ src/Capacitor.Cli/Commands/SessionImporter.cs | 207 ++++++++---------- .../Commands/TranscriptBatchBuffer.cs | 51 +++++ .../Antigravity/AntigravityImportSource.cs | 10 +- .../Harness/Copilot/CopilotImportSource.cs | 3 +- .../Harness/Cursor/CursorImportSource.cs | 2 + .../Harness/Gemini/GeminiImportSource.cs | 10 +- .../Harness/Kiro/KiroImportSource.cs | 3 +- .../Harness/OpenCode/OpenCodeImportSource.cs | 8 +- .../Harness/Pi/PiImportSource.cs | 3 +- .../ImportEndReassertTests.cs | 1 + .../Commands/ImportChainsTests.cs | 7 + .../Commands/ImportVisibilityTests.cs | 1 + .../Commands/SessionImporterProgressTests.cs | 136 ++++++++++++ .../Commands/TranscriptBatchBufferTests.cs | 41 ++++ 18 files changed, 416 insertions(+), 132 deletions(-) create mode 100644 src/Capacitor.Cli/Commands/TranscriptBatchBuffer.cs create mode 100644 test/Capacitor.Cli.Tests.Unit/Commands/TranscriptBatchBufferTests.cs diff --git a/docs/CHANGES.md b/docs/CHANGES.md index 84352d19a..660edca2a 100644 --- a/docs/CHANGES.md +++ b/docs/CHANGES.md @@ -6,6 +6,20 @@ diff. `CLAUDE.md` holds the invariants; `docs/superpowers/specs/` holds the full Not release notes. Each entry is written as of the change that produced it and is not revised as the code moves on; where an entry disagrees with the code, the code wins. +## An import batch closes on bytes as well as lines + +**#814** closes a transcript batch at 4 MiB of line content as well as at 100 lines, and reports +what an import leaves behind. A Codex rollout's `compacted` line re-carries every earlier user +message, so a hundred of them could exceed the 30 MB body Kestrel binds by default; the server +answered 413 and the importer, which read the status only for a strict caller, counted the batch as +sent. The budget is measured on raw UTF-8 bytes and sits a third of the limit under the worst JSON +escaping expansion, so it holds whatever a line contains. A single line over the budget cannot be +split and is skipped rather than posted in a batch that would be refused. Both the skip and a +refused batch travel on the import progress channel as warnings carrying their session id: a routed +source shares one sink across the run, and a stderr line written under the live progress region is +erased by its next frame. The lines of a dropped batch still count as sent; the figure is what was +posted, and the warning above it is the record of what was not kept. + ## Read a linked pull request inside the workspace The work-context pane now has a compact PR card and a wide native reader beside diff --git a/src/Capacitor.Cli/Commands/IImportSource.cs b/src/Capacitor.Cli/Commands/IImportSource.cs index 1169c760f..084b4798c 100644 --- a/src/Capacitor.Cli/Commands/IImportSource.cs +++ b/src/Capacitor.Cli/Commands/IImportSource.cs @@ -49,12 +49,16 @@ internal sealed record ClassifyContext( /// effective --private flag from the orchestrator; DefaultVisibility carries /// the Step 3 setup visibility choice, or null for standalone `kcap import`. /// Neither is read directly by a source — is. +/// Progress is the sink a source hands to every +/// call so an reaches the user; it is shared by every session of the +/// run, which is why a warning carries its own session id. /// internal sealed record ImportContext( - HttpClient HttpClient, - string BaseUrl, - bool ForcePrivate, - string? DefaultVisibility = null) { + HttpClient HttpClient, + string BaseUrl, + bool ForcePrivate, + string? DefaultVisibility = null, + IProgress? Progress = null) { /// /// The default_visibility to stamp on a session-start, or null to leave the field off. /// An omitted stamp is not "no default" — the server coalesces an absent one to diff --git a/src/Capacitor.Cli/Commands/ImportCommand.cs b/src/Capacitor.Cli/Commands/ImportCommand.cs index 84ef03187..828d903f3 100644 --- a/src/Capacitor.Cli/Commands/ImportCommand.cs +++ b/src/Capacitor.Cli/Commands/ImportCommand.cs @@ -1308,6 +1308,11 @@ or ClassificationStatus.AlreadyLoaded var summaryFailures = new ConcurrentBag<(string SessionId, string Reason)>(); var importedSessionIds = new ConcurrentBag(); + void WarnSession(string sessionId, string message) => display.Line( + $"! {sessionId}: {message}", + $"[yellow]![/] [cyan]{Markup.Escape(sessionId)}[/] {Markup.Escape(message)}" + ); + var events = new ChainWorkerEvents { OnSessionStarted = (_, _) => { }, // non-TTY: session start is silent; TTY overrides below OnSubagentStarted = (_, _, _) => { }, // non-TTY: subagent start is silent; TTY overrides below @@ -1320,6 +1325,7 @@ or ClassificationStatus.AlreadyLoaded $"Skipping {sid} [{reason}]", FormatSkippedReasonMarkup(sid, reason) ), + OnSessionWarning = (_, sid, message) => WarnSession(sid, message), OnSessionEnded = (_, c, outcome, lines) => { importedSessionIds.Add(c.SessionId); @@ -1673,7 +1679,10 @@ string LoadingDesc(int slot, string sid, string verb) => HttpClient: httpClient, BaseUrl: baseUrl, ForcePrivate: forcePrivate, - DefaultVisibility: defaultVisibility + DefaultVisibility: defaultVisibility, + Progress: new CallbackProgress(ev => { + if (ev is ImportWarning w) WarnSession(w.SessionId, w.Message); + }) ); async Task ImportOne(SessionClassification c) { @@ -2843,6 +2852,9 @@ internal sealed record ChainWorkerEvents { /// Fired when a session import fails on a worker slot. public required Action OnSessionErrored { get; init; } // slot, sessionId, reason + /// Fired for an : content the import left behind without failing. + public required Action OnSessionWarning { get; init; } // slot, sessionId, message + /// /// Fired after a session import completes (loaded or resumed). The slot is /// available for the next session as soon as this returns. @@ -3001,6 +3013,7 @@ internal enum SessionImportOutcome { Loaded, Resumed, Errored } case BatchFlushed { AgentId: null } bf: events.OnSessionProgress(slot, bf.LinesAdded, sendableTotal); break; case SubagentStarted ss: events.OnSubagentStarted(slot, session.SessionId, ss.AgentId); break; case SubagentFinished sf: events.OnSubagentFinished(slot, session.SessionId, sf.AgentId, sf.LinesSent); break; + case ImportWarning w: events.OnSessionWarning(slot, w.SessionId, w.Message); break; } } ); diff --git a/src/Capacitor.Cli/Commands/ImportProgress.cs b/src/Capacitor.Cli/Commands/ImportProgress.cs index 8b33faeb6..b3406c2fc 100644 --- a/src/Capacitor.Cli/Commands/ImportProgress.cs +++ b/src/Capacitor.Cli/Commands/ImportProgress.cs @@ -19,3 +19,27 @@ public sealed record SubagentStarted(string AgentId) : ImportProgress; /// Fired after a subagent's transcript has been fully streamed. public sealed record SubagentFinished(string AgentId, int LinesSent) : ImportProgress; + +/// +/// Content the importer left behind without failing the session. Every sink must show these: +/// an import that reports success while a warning went unrendered is a silent loss. The session +/// id travels on the event because a routed source's sink is shared by every session of the run. +/// +public abstract record ImportWarning(string SessionId, string? AgentId) : ImportProgress { + public abstract string Message { get; } + + protected string Scope => AgentId is null ? "" : $"subagent {AgentId} "; +} + +/// A line that alone exceeds the batch byte budget; it cannot be split, so it is not sent. +public sealed record LineSkipped(string SessionId, string? AgentId, int LineNumber, int Bytes) + : ImportWarning(SessionId, AgentId) { + public override string Message => + $"{Scope}line {LineNumber} skipped: {Bytes} bytes exceeds the {TranscriptBatchBuffer.MaxBytes >> 20} MiB per-line limit"; +} + +/// A batch the server refused, or that never reached it, on a path that keeps going past the loss. +public sealed record BatchDropped(string SessionId, string? AgentId, int FirstLineNumber, int LastLineNumber, string Reason) + : ImportWarning(SessionId, AgentId) { + public override string Message => $"{Scope}lines {FirstLineNumber}-{LastLineNumber} dropped: {Reason}"; +} diff --git a/src/Capacitor.Cli/Commands/SessionImporter.cs b/src/Capacitor.Cli/Commands/SessionImporter.cs index 32d427bee..3ebccd1b5 100644 --- a/src/Capacitor.Cli/Commands/SessionImporter.cs +++ b/src/Capacitor.Cli/Commands/SessionImporter.cs @@ -78,9 +78,7 @@ internal static async Task ImportSessionAsync( // Read the main transcript line by line, batching and flushing as needed, // with agent lifecycle events inserted at the right positions. - var batchLines = new List(); - var batchLineNumbers = new List(); - const int batchSize = 100; + var batch = new TranscriptBatchBuffer(); await using var stream = new FileStream(transcriptPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var reader = new StreamReader(stream); @@ -93,14 +91,7 @@ internal static async Task ImportSessionAsync( foreach (var (agentId, firstLine) in agentFirstLine) { if (firstLine == lineIndex && !sentAgents.Contains(agentId) && agentMap.TryGetValue(agentId, out var agentPath)) { // Flush the current batch before inserting agent lifecycle - if (batchLines.Count > 0) { - await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId: null, batchLines, batchLineNumbers, vendor); - var flushed = batchLines.Count; - totalSent += flushed; - progress?.Report(new BatchFlushed(AgentId: null, flushed)); - batchLines.Clear(); - batchLineNumbers.Clear(); - } + if (!batch.IsEmpty) await FlushAsync(); // Send agent lifecycle: start → transcript → stop agentTypes.TryGetValue(agentId, out var agentType); @@ -111,29 +102,24 @@ internal static async Task ImportSessionAsync( } if (!string.IsNullOrWhiteSpace(line)) { - batchLines.Add(line); - batchLineNumbers.Add(lineIndex); - } + var bytes = TranscriptBatchBuffer.SizeOf(line); - lineIndex++; + if (bytes > TranscriptBatchBuffer.MaxBytes) { + progress?.Report(new LineSkipped(sessionId, AgentId: null, lineIndex, bytes)); + } else { + if (!batch.Fits(bytes)) await FlushAsync(); + + batch.Add(line, lineIndex, bytes); - if (batchLines.Count >= batchSize) { - await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId: null, batchLines, batchLineNumbers, vendor); - var flushed = batchLines.Count; - totalSent += flushed; - progress?.Report(new BatchFlushed(AgentId: null, flushed)); - batchLines.Clear(); - batchLineNumbers.Clear(); + if (batch.IsFull) await FlushAsync(); + } } + + lineIndex++; } // Flush remaining main transcript lines - if (batchLines.Count > 0) { - await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId: null, batchLines, batchLineNumbers, vendor); - var flushed = batchLines.Count; - totalSent += flushed; - progress?.Report(new BatchFlushed(AgentId: null, flushed)); - } + if (!batch.IsEmpty) await FlushAsync(); // Send any agents that had transcript files but NO progress marker in the // main session (e.g., compact agents like acompact-*) as a fallback at the end. @@ -188,6 +174,9 @@ await PostSubagentHookAsync(httpClient, baseUrl, "subagent-stop", } return new ImportResult(sessionId, agentIds, totalSent); + + async Task FlushAsync() => + totalSent += await FlushBatchAsync(httpClient, baseUrl, sessionId, agentId: null, batch, vendor, failOnError: false, progress); } /// POSTs one subagent lifecycle hook; false on any failure so the caller can @@ -512,28 +501,24 @@ static async Task SendAgentLifecycle( } /// - /// Send transcript lines in batches of 100 for a given file (main or agent). + /// Send a file's transcript lines (main or agent) in batches; + /// says when one closes. A line over the byte budget cannot be split, so it is reported as a + /// and left out rather than posted in a batch the server would refuse. /// /// /// Stamped on the outgoing so the server picks the matching /// normalizer. /// + /// + /// A refused or undeliverable batch throws instead of being reported as a + /// and passed over. + /// /// - /// checked immediately BEFORE - /// every batch POST (including the very first) AND immediately AFTER it. When it returns true, - /// delivery aborts by throwing — a pre-POST trip - /// skips the pending batch entirely; a post-POST trip has already sent that batch but stops - /// before any further one. No remaining batches (or, for the Cursor caller, the child - /// lifecycle/transcript POSTs that follow this call) are ever sent past a trip either way. - /// Cursor's live rewrite guard can quarantine a session AFTER this method's caller last checked - /// (its own check only runs once, before the first byte of this multi-batch send), and without a - /// check re-run inside the loop every remaining 100-line batch would still be posted. The - /// post-POST check specifically closes the window a pre-POST-only check leaves open when a - /// marker appears WHILE a batch is in flight and there is no NEXT batch to gate — e.g. a - /// transcript of <=100 lines (a single, final batch) — where the method would otherwise return - /// normally with the caller none the wiser. The predicate should be cheap (a marker-file check, - /// not a re-run of any correlator) — the caller is expected to close over an already-resolved - /// identity rather than recompute it per batch. + /// Checked immediately before and after every batch POST, the first and the last included; a trip + /// throws so nothing further is posted. Cursor's + /// live rewrite guard can quarantine a session while a batch is in flight, and the post-POST check + /// is what catches a marker written during the only batch of a short transcript. Keep it cheap: a + /// marker check over an already-resolved identity, not a correlator run per batch. /// internal static async Task SendTranscriptBatches( HttpClient httpClient, @@ -550,10 +535,8 @@ internal static async Task SendTranscriptBatches( ) { if (!File.Exists(filePath)) return 0; - var totalSent = 0; - var batchLines = new List(); - var batchLineNumbers = new List(); - const int batchSize = 100; + var totalSent = 0; + var batch = new TranscriptBatchBuffer(); await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var reader = new StreamReader(stream); @@ -568,95 +551,95 @@ internal static async Task SendTranscriptBatches( } if (!string.IsNullOrWhiteSpace(line)) { - batchLines.Add(line); - batchLineNumbers.Add(checked(lineIndex + lineNumberOffset)); - } + var bytes = TranscriptBatchBuffer.SizeOf(line); + var lineNumber = checked(lineIndex + lineNumberOffset); - lineIndex++; + if (bytes > TranscriptBatchBuffer.MaxBytes) { + progress?.Report(new LineSkipped(sessionId, agentId, lineNumber, bytes)); + } else { + if (!batch.Fits(bytes)) await FlushAsync(); - if (batchLines.Count >= batchSize) { - if (abortDelivery?.Invoke() == true) throw new TranscriptDeliveryAbortedException(); - - await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId, batchLines, batchLineNumbers, vendor, failOnError); - var flushed = batchLines.Count; - totalSent += flushed; - progress?.Report(new BatchFlushed(agentId, flushed)); - batchLines.Clear(); - batchLineNumbers.Clear(); - - // re-check IMMEDIATELY after the POST too, not - // only before the NEXT one. Before this, a marker written while THIS batch's POST was - // in flight was only ever caught by the pre-POST check ahead of a batch that might - // never come (see below) — for a transcript with no further lines to send (this was - // the LAST batch, e.g. exactly 100/200/... lines), there IS no next predicate - // invocation at all, and the method returned normally with the caller none the wiser. - if (abortDelivery?.Invoke() == true) throw new TranscriptDeliveryAbortedException(); + batch.Add(line, lineNumber, bytes); + + if (batch.IsFull) await FlushAsync(); + } } + + lineIndex++; } - if (batchLines.Count > 0) { + if (!batch.IsEmpty) await FlushAsync(); + + return totalSent; + + async Task FlushAsync() { if (abortDelivery?.Invoke() == true) throw new TranscriptDeliveryAbortedException(); - await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId, batchLines, batchLineNumbers, vendor, failOnError); - var flushed = batchLines.Count; - totalSent += flushed; - progress?.Report(new BatchFlushed(agentId, flushed)); + totalSent += await FlushBatchAsync(httpClient, baseUrl, sessionId, agentId, batch, vendor, failOnError, progress); - // same post-POST re-check for the trailing/only - // batch (a transcript of <=100 lines never enters the loop branch above at all, so - // WITHOUT this check here specifically, a marker written while this — the ONLY — POST was - // in flight was never observed anywhere: SendTranscriptBatches returned normally and the - // caller (CursorImportSource) proceeded straight into child lifecycle / normal completion. if (abortDelivery?.Invoke() == true) throw new TranscriptDeliveryAbortedException(); } - - return totalSent; } /// - /// thrown by when its - /// abortDelivery predicate trips mid-delivery (between batches). Distinct from a generic - /// send failure so a caller that wants to react specially (Cursor's best-effort session-end — - /// see ) can catch it before a broader - /// catch-all. + /// Thrown by when its abortDelivery predicate trips. + /// Distinct from a generic send failure so Cursor's best-effort session-end (see + /// ) can catch it ahead of a broader catch-all. /// internal sealed class TranscriptDeliveryAbortedException : Exception; - static async Task PostTranscriptBatch( - HttpClient httpClient, - string baseUrl, - string sessionId, - string? agentId, - List lines, - List lineNumbers, - HarnessId vendor, - bool failOnError = false + /// + /// POSTs the buffered lines, empties the buffer and returns how many lines it held. A strict caller + /// gets a refusal or transport failure as an exception naming the line range; a lenient one gets it + /// reported as a and keeps going, with the lines still counted as posted. + /// + static async Task FlushBatchAsync( + HttpClient httpClient, + string baseUrl, + string sessionId, + string? agentId, + TranscriptBatchBuffer batch, + HarnessId vendor, + bool failOnError, + IProgress? progress ) { - var batch = new TranscriptBatch { + var payload = new TranscriptBatch { SessionId = sessionId, AgentId = agentId, - Lines = [.. lines], - LineNumbers = [.. lineNumbers], + Lines = [.. batch.Lines], + LineNumbers = [.. batch.LineNumbers], // Claude stays absent on the wire: an older server reads a missing vendor as Claude, // and the tag is what selects any other normalizer. Vendor = vendor is HarnessId.Claude ? null : vendor.VendorId, - // Fail-closed callers (failOnError) also want server-side normalization failures to - // surface as non-2xx (server only does so when Strict), not just transport/HTTP errors. + // The server reports a normalization failure as non-2xx only for a strict batch. Strict = failOnError }; - var json = JsonSerializer.Serialize(batch, CapacitorJsonContext.Default.TranscriptBatch); + var json = JsonSerializer.Serialize(payload, CapacitorJsonContext.Default.TranscriptBatch); using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var first = batch.FirstLineNumber; + var last = batch.LastLineNumber; + string? loss; try { using var resp = await httpClient.PostWithRetryAsync($"{baseUrl}/hooks/transcript", content); - if (failOnError && !resp.IsSuccessStatusCode) - throw new HttpRequestException($"transcript batch rejected: HTTP {(int)resp.StatusCode}"); - } catch (HttpRequestException) { - // Strict callers (OpenCode import) abort the import; default callers - // (Claude/Codex/Cursor/Copilot/Gemini/Kiro/Pi) log but continue — unchanged. - if (failOnError) throw; + + loss = resp.IsSuccessStatusCode ? null : $"HTTP {(int)resp.StatusCode}"; + } catch (HttpRequestException ex) when (!failOnError) { + loss = ex.Message; + } + + if (loss is not null) { + if (failOnError) throw new HttpRequestException($"transcript batch lines {first}-{last} rejected: {loss}"); + + progress?.Report(new BatchDropped(sessionId, agentId, first, last, loss)); } + + var flushed = batch.Count; + progress?.Report(new BatchFlushed(agentId, flushed)); + batch.Clear(); + + return flushed; } /// @@ -683,9 +666,9 @@ where fileName.StartsWith("agent-") } /// - /// Count the non-blank lines from (inclusive) to - /// EOF — i.e. the number of lines / - /// will actually POST for the main transcript. + /// Count the lines from (inclusive) to EOF that + /// / will actually + /// POST for the main transcript: non-blank and within the batch byte budget. /// This is the denominator for the per-session import progress bar: the sum of /// every with a null AgentId equals this count. /// Best-effort — returns 0 on a missing file or any I/O error, which callers @@ -702,7 +685,9 @@ internal static int CountSendableLines(string filePath, int startLine = 0) { var lineIndex = 0; while (reader.ReadLine() is { } line) { - if (lineIndex >= startLine && !string.IsNullOrWhiteSpace(line)) count++; + if (lineIndex >= startLine + && !string.IsNullOrWhiteSpace(line) + && TranscriptBatchBuffer.SizeOf(line) <= TranscriptBatchBuffer.MaxBytes) count++; lineIndex++; } diff --git a/src/Capacitor.Cli/Commands/TranscriptBatchBuffer.cs b/src/Capacitor.Cli/Commands/TranscriptBatchBuffer.cs new file mode 100644 index 000000000..c1211ab0d --- /dev/null +++ b/src/Capacitor.Cli/Commands/TranscriptBatchBuffer.cs @@ -0,0 +1,51 @@ +using System.Text; + +namespace Capacitor.Cli.Commands; + +/// +/// Lines waiting for one /hooks/transcript POST. A batch closes at +/// lines or of line content, whichever comes first, so a run of large lines +/// cannot grow a body past what the server binds. +/// +sealed class TranscriptBatchBuffer { + public const int MaxLines = 100; + + /// + /// Raw UTF-8 bytes of line content per batch. Kestrel refuses a body over 30 MB by default and + /// JSON escaping can triple a line (non-ASCII becomes \uXXXX), so the raw budget stays + /// well inside that even for a batch of pathological lines. + /// + public const int MaxBytes = 4 * 1024 * 1024; + + readonly List _lines = []; + readonly List _lineNumbers = []; + int _bytes; + + public int Count => _lines.Count; + public bool IsEmpty => _lines.Count == 0; + public bool IsFull => _lines.Count >= MaxLines; + + public IReadOnlyList Lines => _lines; + public IReadOnlyList LineNumbers => _lineNumbers; + + public int FirstLineNumber => _lineNumbers[0]; + public int LastLineNumber => _lineNumbers[^1]; + + /// The bytes a line contributes to a batch; over it can never be posted. + public static int SizeOf(string line) => Encoding.UTF8.GetByteCount(line); + + /// False when the line would push this batch over its byte budget, so the caller flushes first. + public bool Fits(int bytes) => _bytes + bytes <= MaxBytes; + + public void Add(string line, int lineNumber, int bytes) { + _lines.Add(line); + _lineNumbers.Add(lineNumber); + _bytes += bytes; + } + + public void Clear() { + _lines.Clear(); + _lineNumbers.Clear(); + _bytes = 0; + } +} diff --git a/src/Capacitor.Cli/Harness/Antigravity/AntigravityImportSource.cs b/src/Capacitor.Cli/Harness/Antigravity/AntigravityImportSource.cs index 37d4070ff..22a72bcb8 100644 --- a/src/Capacitor.Cli/Harness/Antigravity/AntigravityImportSource.cs +++ b/src/Capacitor.Cli/Harness/Antigravity/AntigravityImportSource.cs @@ -246,7 +246,7 @@ public async Task ImportSessionAsync( // lifecycle-only repair path (subagent-start+stop, no content resend) contributes // false. This outcome stays hardcoded Skipped — only SentChildContent is threaded // through below. - var sentChildContent = await ImportChildrenAsync(ctx.HttpClient, ctx.BaseUrl, c.SessionId, c.SourceMeta!, ct); + var sentChildContent = await ImportChildrenAsync(ctx.HttpClient, ctx.BaseUrl, c.SessionId, c.SourceMeta!, ctx.Progress, ct); // Explicit gen_metadata usage pass — even though AlreadyLoaded sends zero real // transcript lines (the content watermark is already at the tip), a session @@ -279,7 +279,7 @@ public async Task ImportSessionAsync( try { sent = await SessionImporter.SendTranscriptBatches( httpClient: ctx.HttpClient, baseUrl: ctx.BaseUrl, sessionId: c.SessionId, - filePath: transcriptPath, agentId: null, startLine: startLine, vendor: Vendor); + filePath: transcriptPath, agentId: null, startLine: startLine, vendor: Vendor, progress: ctx.Progress); } catch (OperationCanceledException) { throw; } catch { @@ -294,7 +294,7 @@ public async Task ImportSessionAsync( // Children as subagents — BEFORE session-end so SubagentCompleted precedes SessionEnded. // Subagent failures don't fail the (already-imported) parent; a re-import retries. - await ImportChildrenAsync(ctx.HttpClient, ctx.BaseUrl, c.SessionId, c.SourceMeta!, ct); + await ImportChildrenAsync(ctx.HttpClient, ctx.BaseUrl, c.SessionId, c.SourceMeta!, ctx.Progress, ct); if (!await PostHookAsync(ctx.HttpClient, ctx.BaseUrl, "session-end/antigravity", BuildSessionEndPayload(c.SessionId, c.Meta.Cwd, c.Meta.LastTimestamp), ct)) @@ -313,7 +313,7 @@ public async Task ImportSessionAsync( /// async Task ImportChildrenAsync( HttpClient client, string baseUrl, string rootId, - IReadOnlyDictionary sourceMeta, CancellationToken ct) { + IReadOnlyDictionary sourceMeta, IProgress? progress, CancellationToken ct) { if (!sourceMeta.TryGetValue("Children", out var kidsObj) || kidsObj is not List { Count: > 0 } children) return false; @@ -405,7 +405,7 @@ await PostHookAsync(client, baseUrl, "subagent-stop", childSent = await SessionImporter.SendTranscriptBatches( httpClient: client, baseUrl: baseUrl, sessionId: rootId, filePath: childTranscript, agentId: childAgentId, startLine: childStartLine, vendor: Vendor, - failOnError: true); + failOnError: true, progress: progress); } catch (OperationCanceledException) { throw; } catch { diff --git a/src/Capacitor.Cli/Harness/Copilot/CopilotImportSource.cs b/src/Capacitor.Cli/Harness/Copilot/CopilotImportSource.cs index d525d1199..24bb7d093 100644 --- a/src/Capacitor.Cli/Harness/Copilot/CopilotImportSource.cs +++ b/src/Capacitor.Cli/Harness/Copilot/CopilotImportSource.cs @@ -300,7 +300,8 @@ CancellationToken ct filePath: transcriptPath, agentId: null, startLine: startLine, - vendor: Vendor); + vendor: Vendor, + progress: ctx.Progress); } catch { return ImportOutcome.Failed; } diff --git a/src/Capacitor.Cli/Harness/Cursor/CursorImportSource.cs b/src/Capacitor.Cli/Harness/Cursor/CursorImportSource.cs index 49103bd4d..9ba9b7120 100644 --- a/src/Capacitor.Cli/Harness/Cursor/CursorImportSource.cs +++ b/src/Capacitor.Cli/Harness/Cursor/CursorImportSource.cs @@ -580,6 +580,7 @@ await PostSyntheticHookAsync( agentId: null, startLine: startLine, vendor: Vendor, + progress: ctx.Progress, abortDelivery: () => _markers.IsQuarantined(quarantineIdentity)); } catch (SessionImporter.TranscriptDeliveryAbortedException) { // Quarantine tripped mid-delivery — no children/remaining batches (we return before @@ -856,6 +857,7 @@ CancellationToken ct agentId: agentId, startLine: startLine, vendor: Vendor, + progress: ctx.Progress, failOnError: true, abortDelivery: () => _markers.IsQuarantined(quarantineIdentity)); } catch (SessionImporter.TranscriptDeliveryAbortedException) { diff --git a/src/Capacitor.Cli/Harness/Gemini/GeminiImportSource.cs b/src/Capacitor.Cli/Harness/Gemini/GeminiImportSource.cs index ac1fcc8cb..0946abe41 100644 --- a/src/Capacitor.Cli/Harness/Gemini/GeminiImportSource.cs +++ b/src/Capacitor.Cli/Harness/Gemini/GeminiImportSource.cs @@ -235,7 +235,8 @@ CancellationToken ct filePath: transcriptPath, agentId: null, startLine: startLine, - vendor: Vendor); + vendor: Vendor, + progress: ctx.Progress); } catch { return ImportOutcome.Failed; } @@ -251,7 +252,7 @@ CancellationToken ct // re-run whether or not anything is actually new. Accepted — still strictly better // than defaulting false (which would risk hiding genuinely-new subagent content // behind the lifecycle-only-replay label). - var sentChildContent = await ImportSubagentsAsync(ctx.HttpClient, ctx.BaseUrl, classification.SessionId, transcriptPath, ct); + var sentChildContent = await ImportSubagentsAsync(ctx.HttpClient, ctx.BaseUrl, classification.SessionId, transcriptPath, ctx.Progress, ct); var endOk = await PostSyntheticHookAsync( ctx.HttpClient, ctx.BaseUrl, "session-end/gemini", @@ -322,7 +323,8 @@ static async Task PostSyntheticHookAsync( /// residual). /// async Task ImportSubagentsAsync( - HttpClient client, string baseUrl, string parentSessionIdDashless, string transcriptPath, CancellationToken ct + HttpClient client, string baseUrl, string parentSessionIdDashless, string transcriptPath, + IProgress? progress, CancellationToken ct ) { // Gemini has no completeness-fingerprint ledger (unlike OpenCode), so the omitted ids // are unused here — the diagnostic below only needs the count (and, when the below-cap @@ -373,7 +375,7 @@ async Task ImportSubagentsAsync( subSent = await SessionImporter.SendTranscriptBatches( httpClient: client, baseUrl: baseUrl, sessionId: parentSessionIdDashless, filePath: d.File, - agentId: agentId, startLine: 0, vendor: Vendor, failOnError: true); + agentId: agentId, startLine: 0, vendor: Vendor, failOnError: true, progress: progress); } catch { continue; // leave subagent-stop unsent; a re-import retries (idempotent) } diff --git a/src/Capacitor.Cli/Harness/Kiro/KiroImportSource.cs b/src/Capacitor.Cli/Harness/Kiro/KiroImportSource.cs index 048063e7e..b88d791dd 100644 --- a/src/Capacitor.Cli/Harness/Kiro/KiroImportSource.cs +++ b/src/Capacitor.Cli/Harness/Kiro/KiroImportSource.cs @@ -285,7 +285,8 @@ CancellationToken ct filePath: sendPath, agentId: null, startLine: startLine, - vendor: Vendor); + vendor: Vendor, + progress: ctx.Progress); } catch { return ImportOutcome.Failed; } finally { diff --git a/src/Capacitor.Cli/Harness/OpenCode/OpenCodeImportSource.cs b/src/Capacitor.Cli/Harness/OpenCode/OpenCodeImportSource.cs index 75e2f929c..58f38afec 100644 --- a/src/Capacitor.Cli/Harness/OpenCode/OpenCodeImportSource.cs +++ b/src/Capacitor.Cli/Harness/OpenCode/OpenCodeImportSource.cs @@ -209,7 +209,7 @@ public async Task ImportSessionAsync( sent = await SessionImporter.SendTranscriptBatches( httpClient: ctx.HttpClient, baseUrl: ctx.BaseUrl, sessionId: c.SessionId, filePath: tmpFile, agentId: null, startLine: 0, vendor: Vendor, - lineNumberOffset: lineOffset, failOnError: true); + lineNumberOffset: lineOffset, failOnError: true, progress: ctx.Progress); } catch (OperationCanceledException) { throw; // cancellation is not an import failure — let it propagate } catch { @@ -237,7 +237,7 @@ public async Task ImportSessionAsync( var descendantsOk = true; OperationCanceledException? cancellation = null; try { - await ImportDescendantsAsync(ctx.HttpClient, ctx.BaseUrl, c.SessionId, ct); + await ImportDescendantsAsync(ctx.HttpClient, ctx.BaseUrl, c.SessionId, ctx.Progress, ct); } catch (OperationCanceledException oce) { cancellation = oce; } catch { @@ -295,7 +295,7 @@ public async Task ImportSessionAsync( /// silent) via a stderr diagnostic. Throws on the first descendant whose lifecycle can't be /// posted (caller wraps this in a finally-style re-close). /// - async Task ImportDescendantsAsync(HttpClient client, string baseUrl, string rootId, CancellationToken ct) { + async Task ImportDescendantsAsync(HttpClient client, string baseUrl, string rootId, IProgress? progress, CancellationToken ct) { using var db = new OpenCodeDb(_dbPath); var (descendants, omitted, _, countTruncated) = db.QueryDescendants(rootId); // BFS, per-level ordered like QueryChildren @@ -335,7 +335,7 @@ async Task ImportDescendantsAsync(HttpClient client, string baseUrl, string root await SessionImporter.SendTranscriptBatches( httpClient: client, baseUrl: baseUrl, sessionId: rootId, filePath: tmp, agentId: agentId, startLine: 0, vendor: Vendor, - lineNumberOffset: childOffset, failOnError: true); + lineNumberOffset: childOffset, failOnError: true, progress: progress); if (!await PostHookAsync(client, baseUrl, "subagent-stop", OpenCodeSubagentDiscovery.BuildStopPayload(rootId, agentId, agentType, tmp), ct)) diff --git a/src/Capacitor.Cli/Harness/Pi/PiImportSource.cs b/src/Capacitor.Cli/Harness/Pi/PiImportSource.cs index 23d0c7c85..797cd9d68 100644 --- a/src/Capacitor.Cli/Harness/Pi/PiImportSource.cs +++ b/src/Capacitor.Cli/Harness/Pi/PiImportSource.cs @@ -268,7 +268,8 @@ CancellationToken ct filePath: transcriptPath, agentId: null, startLine: startLine, - vendor: Vendor); + vendor: Vendor, + progress: ctx.Progress); } catch { return ImportOutcome.Failed; } diff --git a/test/Capacitor.Cli.Tests.Integration/ImportEndReassertTests.cs b/test/Capacitor.Cli.Tests.Integration/ImportEndReassertTests.cs index 78eec95c3..a94848edb 100644 --- a/test/Capacitor.Cli.Tests.Integration/ImportEndReassertTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/ImportEndReassertTests.cs @@ -59,6 +59,7 @@ static string WriteTranscript(string dir, string sessionId) { OnSubagentFinished = (_, _, _, _) => { }, OnSessionProgress = (_, _, _) => { }, OnSessionErrored = (_, _, _) => { }, + OnSessionWarning = (_, _, _) => { }, OnSessionEnded = (_, _, _, _) => { }, OnTitleTaskReady = _ => { }, OnBackgroundWorkReady = _ => { }, diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportChainsTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportChainsTests.cs index 2f17ebedb..4e79f466e 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportChainsTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportChainsTests.cs @@ -95,6 +95,7 @@ public async Task ImportChainsAsync_counts_loaded_sessions() { OnSubagentFinished = (_, _, _, _) => { }, OnSessionProgress = (_, _, _) => { }, OnSessionErrored = (_, _, _) => { }, + OnSessionWarning = (_, _, _) => { }, OnSessionEnded = (_, c, _, _) => completedLines.Add($"Loading {c.SessionId}..."), OnTitleTaskReady = _ => { }, OnBackgroundWorkReady = _ => { }, @@ -132,6 +133,7 @@ public async Task ImportChainsAsync_reports_per_session_progress() { OnSubagentFinished = (_, _, _, _) => { }, OnSessionProgress = (_, lines, total) => { progressLines.Add(lines); progressTotals.Add(total); }, OnSessionErrored = (_, _, _) => { }, + OnSessionWarning = (_, _, _) => { }, OnSessionEnded = (_, _, _, _) => { }, OnTitleTaskReady = _ => { }, OnBackgroundWorkReady = _ => { }, @@ -168,6 +170,7 @@ public async Task ImportChainsAsync_skips_line_precount_when_progress_disabled() OnSubagentFinished = (_, _, _, _) => { }, OnSessionProgress = (_, _, total) => progressTotals.Add(total), OnSessionErrored = (_, _, _) => { }, + OnSessionWarning = (_, _, _) => { }, OnSessionEnded = (_, _, _, _) => { }, OnTitleTaskReady = _ => { }, OnBackgroundWorkReady = _ => { }, @@ -198,6 +201,7 @@ public async Task ImportChainsAsync_processes_within_chain_in_order() { OnSubagentFinished = (_, _, _, _) => { }, OnSessionProgress = (_, _, _) => { }, OnSessionErrored = (_, _, _) => { }, + OnSessionWarning = (_, _, _) => { }, OnSessionEnded = (_, c, _, _) => order.Enqueue($"Loading {c.SessionId}..."), OnTitleTaskReady = _ => { }, OnBackgroundWorkReady = _ => { }, @@ -229,6 +233,7 @@ public async Task ImportChainsAsync_routes_codex_session_to_vendor_specific_url( OnSubagentFinished = (_, _, _, _) => { }, OnSessionProgress = (_, _, _) => { }, OnSessionErrored = (_, _, _) => { }, + OnSessionWarning = (_, _, _) => { }, OnSessionEnded = (_, _, _, _) => { }, OnTitleTaskReady = _ => { }, OnBackgroundWorkReady = _ => { }, @@ -280,6 +285,7 @@ public async Task ImportChainsAsync_dispatches_independent_chains_in_parallel() OnSubagentFinished = (_, _, _, _) => { }, OnSessionProgress = (_, _, _) => { }, OnSessionErrored = (_, _, _) => { }, + OnSessionWarning = (_, _, _) => { }, OnSessionEnded = (_, _, _, _) => { }, OnTitleTaskReady = _ => { }, OnBackgroundWorkReady = _ => { }, @@ -350,6 +356,7 @@ public async Task ImportChainsAsync_derives_workspace_root_from_the_remapped_cwd OnSubagentFinished = (_, _, _, _) => { }, OnSessionProgress = (_, _, _) => { }, OnSessionErrored = (_, _, _) => { }, + OnSessionWarning = (_, _, _) => { }, OnSessionEnded = (_, _, _, _) => { }, OnTitleTaskReady = _ => { }, OnBackgroundWorkReady = _ => { }, diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportVisibilityTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportVisibilityTests.cs index 530f9cfc9..4a22a19c3 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportVisibilityTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportVisibilityTests.cs @@ -120,6 +120,7 @@ ImportCommand.SessionClassification MakeChainSession( OnSubagentFinished = (_, _, _, _) => { }, OnSessionProgress = (_, _, _) => { }, OnSessionErrored = (_, _, _) => { }, + OnSessionWarning = (_, _, _) => { }, OnSessionEnded = (_, _, _, _) => { }, OnTitleTaskReady = _ => { }, OnBackgroundWorkReady = _ => { }, diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SessionImporterProgressTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SessionImporterProgressTests.cs index f5a0b08d0..ba6ac8e17 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/SessionImporterProgressTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SessionImporterProgressTests.cs @@ -163,4 +163,140 @@ await File.WriteAllLinesAsync(agentPath, [ await Assert.That(subagentBatches.Count).IsGreaterThan(0); await Assert.That(subagentBatches.All(b => b.AgentId == agentId)).IsTrue(); } + + /// A transcript line whose content field carries ASCII characters. + static string LineOf(int contentChars) => + $$$"""{"type":"user","timestamp":"2026-03-15T10:00:00Z","message":{"content":"{{{new string('x', contentChars)}}}"}}"""; + + [Test] + public async Task SendTranscriptBatches_closes_a_batch_on_the_byte_budget_before_the_line_count() { + _server.Given(Request.Create().WithPath("/hooks/transcript").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + + // Three 1.5 MiB lines: the third would take the batch past 4 MiB, so it opens a second one. + using var tmp = TempDir.WithPathTo("transcript.tmp", out var path); + await File.WriteAllLinesAsync(path, Enumerable.Range(0, 3).Select(_ => LineOf(1_572_864))); + + var events = new List(); + var progress = new SyncProgress(events.Add); + + using var client = new HttpClient(); + var totalSent = await SessionImporter.SendTranscriptBatches( + client, _server.Url!, sessionId: "s1", filePath: path, + agentId: null, startLine: 0, progress: progress + ); + + await Assert.That(totalSent).IsEqualTo(3); + + var flushes = events.OfType().ToList(); + await Assert.That(flushes.Count).IsEqualTo(2); + await Assert.That(flushes[0].LinesAdded).IsEqualTo(2); + await Assert.That(flushes[1].LinesAdded).IsEqualTo(1); + await Assert.That(_server.LogEntries.Count).IsEqualTo(2); + } + + [Test] + public async Task SendTranscriptBatches_skips_a_line_over_the_budget_and_reports_it() { + _server.Given(Request.Create().WithPath("/hooks/transcript").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + + // The middle line's content alone fills the budget; with its envelope it is over. + using var tmp = TempDir.WithPathTo("transcript.tmp", out var path); + await File.WriteAllLinesAsync(path, [LineOf(10), LineOf(TranscriptBatchBuffer.MaxBytes), LineOf(10)]); + + var events = new List(); + var progress = new SyncProgress(events.Add); + + using var client = new HttpClient(); + var totalSent = await SessionImporter.SendTranscriptBatches( + client, _server.Url!, sessionId: "s1", filePath: path, + agentId: null, startLine: 0, progress: progress + ); + + await Assert.That(totalSent).IsEqualTo(2); + + var skipped = events.OfType().Single(); + await Assert.That(skipped.SessionId).IsEqualTo("s1"); + await Assert.That(skipped.AgentId).IsNull(); + await Assert.That(skipped.LineNumber).IsEqualTo(1); + await Assert.That(skipped.Bytes).IsGreaterThan(TranscriptBatchBuffer.MaxBytes); + await Assert.That(skipped.Message).Contains("line 1 skipped"); + + var body = _server.LogEntries.Single().RequestMessage.Body; + await Assert.That(body).Contains("\"line_numbers\":[0,2]"); + + // The progress denominator counts what will actually be posted. + await Assert.That(SessionImporter.CountSendableLines(path)).IsEqualTo(2); + } + + [Test] + public async Task SendTranscriptBatches_reports_a_batch_the_server_refuses_and_keeps_going() { + _server.Given(Request.Create().WithPath("/hooks/transcript").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(413)); + + using var tmp = TempDir.WithPathTo("transcript.tmp", out var path); + await File.WriteAllLinesAsync(path, Enumerable.Range(0, 150).Select(_ => LineOf(10))); + + var events = new List(); + var progress = new SyncProgress(events.Add); + + using var client = new HttpClient(); + await SessionImporter.SendTranscriptBatches( + client, _server.Url!, sessionId: "s1", filePath: path, + agentId: "agent-1", startLine: 0, progress: progress + ); + + var dropped = events.OfType().ToList(); + await Assert.That(dropped.Count).IsEqualTo(2); + await Assert.That(dropped[0]).IsEqualTo(new BatchDropped("s1", "agent-1", 0, 99, "HTTP 413")); + await Assert.That(dropped[1]).IsEqualTo(new BatchDropped("s1", "agent-1", 100, 149, "HTTP 413")); + await Assert.That(dropped[0].Message).IsEqualTo("subagent agent-1 lines 0-99 dropped: HTTP 413"); + } + + [Test] + public async Task SendTranscriptBatches_names_the_refused_lines_for_a_strict_caller() { + _server.Given(Request.Create().WithPath("/hooks/transcript").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(413)); + + using var tmp = TempDir.WithPathTo("transcript.tmp", out var path); + await File.WriteAllLinesAsync(path, Enumerable.Range(0, 5).Select(_ => LineOf(10))); + + using var client = new HttpClient(); + var ex = await Assert.ThrowsAsync(async () => { + await SessionImporter.SendTranscriptBatches( + client, _server.Url!, sessionId: "s1", filePath: path, + agentId: null, startLine: 0, failOnError: true + ); + }); + + await Assert.That(ex.Message).Contains("lines 0-4"); + await Assert.That(ex.Message).Contains("HTTP 413"); + } + + [Test] + public async Task ImportSessionAsync_closes_a_main_transcript_batch_on_the_byte_budget() { + _server.Given(Request.Create().WithPath("/hooks/transcript").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + + using var tmp = TempDir.WithPathTo("session.jsonl", out var path); + await File.WriteAllLinesAsync(path, Enumerable.Range(0, 3).Select(_ => LineOf(1_572_864))); + + var events = new List(); + var progress = new SyncProgress(events.Add); + + using var client = new HttpClient(); + var result = await SessionImporter.ImportSessionAsync( + client, _server.Url!, path, sessionId: "s1", + new SessionMetadata { Cwd = "/x" }, encodedCwd: null, + progress: progress + ); + + await Assert.That(result.LinesSent).IsEqualTo(3); + + var flushes = events.OfType().ToList(); + await Assert.That(flushes.Count).IsEqualTo(2); + await Assert.That(flushes[0].LinesAdded).IsEqualTo(2); + await Assert.That(flushes[1].LinesAdded).IsEqualTo(1); + await Assert.That(flushes.All(f => f.AgentId == null)).IsTrue(); + } } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/TranscriptBatchBufferTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/TranscriptBatchBufferTests.cs new file mode 100644 index 000000000..8aee1fa17 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/Commands/TranscriptBatchBufferTests.cs @@ -0,0 +1,41 @@ +using Capacitor.Cli.Commands; + +namespace Capacitor.Cli.Tests.Unit.Commands; + +public class TranscriptBatchBufferTests { + [Test] + public async Task Fills_at_the_line_cap() { + var buffer = new TranscriptBatchBuffer(); + + for (var i = 0; i < TranscriptBatchBuffer.MaxLines - 1; i++) buffer.Add("x", i, 1); + + await Assert.That(buffer.IsFull).IsFalse(); + + buffer.Add("x", TranscriptBatchBuffer.MaxLines - 1, 1); + + await Assert.That(buffer.IsFull).IsTrue(); + await Assert.That(buffer.FirstLineNumber).IsEqualTo(0); + await Assert.That(buffer.LastLineNumber).IsEqualTo(TranscriptBatchBuffer.MaxLines - 1); + } + + [Test] + public async Task Refuses_a_line_that_would_pass_the_byte_budget() { + var buffer = new TranscriptBatchBuffer(); + buffer.Add("big", 0, 3 * 1024 * 1024); + + await Assert.That(buffer.Fits(1024 * 1024)).IsTrue(); + await Assert.That(buffer.Fits(1024 * 1024 + 1)).IsFalse(); + + buffer.Clear(); + + await Assert.That(buffer.IsEmpty).IsTrue(); + await Assert.That(buffer.Fits(TranscriptBatchBuffer.MaxBytes)).IsTrue(); + } + + [Test] + public async Task Measures_a_line_in_utf8_bytes() { + await Assert.That(TranscriptBatchBuffer.SizeOf("x")).IsEqualTo(1); + await Assert.That(TranscriptBatchBuffer.SizeOf("é")).IsEqualTo(2); + await Assert.That(TranscriptBatchBuffer.SizeOf("日")).IsEqualTo(3); + } +} From 96cfca85c59df8d9b6f2bf5235944923af1bdb74 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Tue, 8 Sep 2026 16:11:25 +0200 Subject: [PATCH 2/2] Name the line range on a strict transport failure too (#814) Co-Authored-By: Claude Fable 5.1 --- src/Capacitor.Cli/Commands/SessionImporter.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Capacitor.Cli/Commands/SessionImporter.cs b/src/Capacitor.Cli/Commands/SessionImporter.cs index 3ebccd1b5..5bb556ad7 100644 --- a/src/Capacitor.Cli/Commands/SessionImporter.cs +++ b/src/Capacitor.Cli/Commands/SessionImporter.cs @@ -620,17 +620,19 @@ static async Task FlushBatchAsync( var first = batch.FirstLineNumber; var last = batch.LastLineNumber; string? loss; + Exception? cause = null; try { using var resp = await httpClient.PostWithRetryAsync($"{baseUrl}/hooks/transcript", content); loss = resp.IsSuccessStatusCode ? null : $"HTTP {(int)resp.StatusCode}"; - } catch (HttpRequestException ex) when (!failOnError) { - loss = ex.Message; + } catch (HttpRequestException ex) { + loss = ex.Message; + cause = ex; } if (loss is not null) { - if (failOnError) throw new HttpRequestException($"transcript batch lines {first}-{last} rejected: {loss}"); + if (failOnError) throw new HttpRequestException($"transcript batch lines {first}-{last} failed: {loss}", cause); progress?.Report(new BatchDropped(sessionId, agentId, first, last, loss)); }