From 5053ab8908759fdce9db3ac062c8d194ee7d2a38 Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:46:28 +0800 Subject: [PATCH] acp: explain output-length continuation exhaustion instead of naming it (#1893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bounded output-length allowance (#1646) ends a turn with error.OutputLengthExceeded once its four continuations are spent. Every surface explained that except ACP: agent_length.afterStep calls sayApiError with exhausted_message ("work is partial and has been preserved. Send a new message or resume to continue") just before returning the error, and the line REPL and one-shot path print it — but `root.out` is null inside an ACP session, so that sentence went to stderr and the JSON-RPC reply carried only `{"code":-32603,"message":"OutputLengthExceeded"}`. describeError already had the cure for the identical EmptyCompletion loss (#1802): describe the failure in words the user can act on. Route OutputLengthExceeded through the same arm, reusing agent_length.exhausted_message. No new wording, and nothing about the turn changes: the continuation cap, the retry ladder, and the retained partial history are untouched — only the description the client is handed. Test asserts the description is the explanation (with or without a parked last_api_error) and never @errorName; it fails if the arm is removed. Co-Authored-By: Codegraff --- src/acp_live_turn.zig | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/acp_live_turn.zig b/src/acp_live_turn.zig index 0913c4f8..baaf8aad 100644 --- a/src/acp_live_turn.zig +++ b/src/acp_live_turn.zig @@ -330,10 +330,15 @@ pub const LiveTurn = struct { /// What an ACP client shows for a failed turn. An exhausted empty-reply retry /// carries its own explanation (#1802); a bare error name told the user nothing. +/// #1893: an exhausted output-length continuation is the same shape of loss — +/// the work is partial and kept, and the next message resumes it — so it reuses +/// the explanation agent_length already prints on the line REPL instead of +/// handing the client `OutputLengthExceeded` and no way to act on it. pub fn describeError(err: anyerror, last_api_error: ?[]const u8) []const u8 { return switch (err) { error.ApiError, error.ProviderQuotaExceeded, error.ProviderAuthenticationFailed, error.ProviderAccessDenied => last_api_error orelse "Provider request failed", error.EmptyCompletion => last_api_error orelse @import("agent_empty_completion.zig").exhausted_text, + error.OutputLengthExceeded => last_api_error orelse @import("agent_length.zig").exhausted_message, else => @errorName(err), }; } @@ -389,6 +394,18 @@ test "#1802: an exhausted empty-reply retry is explained, not named" { try std.testing.expectEqualStrings("Interrupted", describeError(error.Interrupted, "stale")); } +test "#1893: an exhausted output-length continuation is explained, not named" { + const length = @import("agent_length.zig"); + // The exhaustion branch calls sayApiError first, so last_api_error already + // holds the same sentence the line REPL prints before it aborts the turn. + try std.testing.expectEqualStrings(length.exhausted_message, describeError(error.OutputLengthExceeded, length.exhausted_message)); + // No parked detail (arena failure, or a producer that never spoke): still + // the explanation, never the bare error name the issue reported. + try std.testing.expectEqualStrings(length.exhausted_message, describeError(error.OutputLengthExceeded, null)); + try std.testing.expect(!std.mem.eql(u8, describeError(error.OutputLengthExceeded, null), @errorName(error.OutputLengthExceeded))); + try std.testing.expect(std.mem.indexOf(u8, describeError(error.OutputLengthExceeded, null), "partial") != null); +} + test "session/prompt receipt is traced even when dedup skips the turn" { const dedup = @import("turn_dedup.zig"); dedup.resetForTest();