Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ import {
AcpmuxRestartGate,
hostErrorShows,
hostIsReconnecting,
hostRefusedTooLarge,
nextRetryDelay,
offersAcpmuxRestart,
} from "./hostRetry";
Expand Down Expand Up @@ -9581,7 +9582,9 @@ function AcpmuxPane() {
if (!cancelled) {
acpWire.lifecycle("handshake failed", { message: String(error) });
setSnapshot((current) => ({ ...current, connection: `connecting: ${String(error)}` }));
setHostError(errorMessage(error) || String(error));
// A chat too large to open says so in plain words; the reply's byte count stays in the log.
const tooLarge = hostRefusedTooLarge(error);
setHostError(tooLarge ? translate("host.tooLarge") : errorMessage(error) || String(error));
failingSince ??= Date.now();
const recovering = hostIsReconnecting(error);
setHostErrorShown(hostErrorShows(error, Date.now() - failingSince));
Expand All @@ -9591,7 +9594,8 @@ function AcpmuxPane() {
setAcpmuxRestartOffered(offersAcpmuxRestart(error));
// Back off so a host without a daemon is not asked four times a second. A Restart acpmux
// in flight asks again itself when it ends.
if (restartGate.mayAsk()) {
// A reply too large to send is the same at every ask: only the line's Retry asks again.
if (!tooLarge && restartGate.mayAsk()) {
retryTimer = window.setTimeout(() => void connectHost(), retryDelay);
retryDelay = nextRetryDelay(retryDelay, reconnect || recovering);
}
Expand Down
8 changes: 8 additions & 0 deletions webviews/src/agent-session/acpmux/HostError.gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@ export default componentEntry<Props>({
},
note: "A refusal whose holder is no app bundle offers the same Restart acpmux.",
},
"too-large": {
props: {
message: "This chat is too large to open. Update acpmux, then Retry.",
initialRetrying: false,
withRetry: true,
},
note: 'acpmux refused the chat\'s open as larger than one message (message.too_large, an acpmux before #28754): said at once in plain words, never the byte count, and the pane does not ask again on its own; Retry still asks (Leo, 2026-10-31: "this is completely hardlocked bc of message size").',
},
waiting: {
props: { message: "", initialRetrying: false, withRetry: false, waitedMs: 95_000 },
play: restartService,
Expand Down
3 changes: 3 additions & 0 deletions webviews/src/agent-session/acpmux/Localizable.xcstrings

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { translate } from "../i18n";
import type { AcpmuxRow, PromptDelivery } from "../model";
import { failedRuns } from "../sendFailure";
import type { AcpmuxRow, AcpmuxSnapshot, PromptDelivery } from "../model";
import type { ComposerAttachment } from "../attachments";
import { failedRuns, failedSendText } from "../sendFailure";
import { messageMenuTarget } from "./messageMenu";
import { DeliveryFailure, FailedSendsContext, type FailedSends } from "./PromptDelivery";

let dom: JSDOM | undefined;
Expand Down Expand Up @@ -159,3 +161,45 @@ test("Retry waits while the folder's trust question is open, and comes back once
expect(button(container, "Retry")!.disabled).toBe(false);
expect(button(container, "Trust folder")).toBeUndefined();
});

/// A prompt past one message (`message.too_large`, acpmux's `rpc::MAX_MESSAGE_BYTES`) is refused the
/// same way at every Retry (Leo, 2026-10-31: "this is completely hardlocked bc of message size"): its
/// card names the attachment to take out and offers Edit, never Retry.
test("a prompt too large to send names its largest attachment and offers Edit, not Retry", async () => {
const tooLarge = { code: -32000, reason: "message.too_large", message: "message too large to send: 16907300 bytes" };
const attachment = (id: string, name: string, bytes: number): ComposerAttachment => ({
id,
kind: "image",
name,
mimeType: "image/png",
size: bytes,
data: "A".repeat(bytes),
});
const attachments = [
attachment("a", "small.png", 10),
attachment("b", "screen-recording.png", 4_000),
attachment("c", "notes.png", 20),
];
const sentence = failedSendText(tooLarge, false, attachments);
expect(sentence).toBe(translate("prompt.failure.tooLargeFile", { name: "screen-recording.png" }));
expect(sentence).toContain("screen-recording.png");
expect(sentence).not.toContain("16907300");
// With no attachment to name (a long pasted text), it still says how to send it.
expect(failedSendText(tooLarge, false)).toBe(translate("prompt.failure.tooLarge"));

const row: AcpmuxRow = {
...failed("a", "look at these", { reason: "message.too_large", code: "-32000", error: tooLarge.message }),
error: sentence,
};
const container = await render(transcript([row], {}));
const card = container.querySelector<HTMLElement>(".cv-user__status--failed")!;
expect(card.dataset.failureCause).toBe("too-large");
expect(card.querySelector(".cv-user__failure-text")!.textContent).toBe(sentence);
expect(button(card, "Retry")).toBeUndefined();
expect(button(card, "Edit")).toBeDefined();
// Nor from the message's menu (the native context menu offers Retry for `retryRowId`).
const other = failed("b", "deploy", { code: "timeout", error: "no answer" });
const snapshot = { rows: [row, other], connection: "connected" } as unknown as AcpmuxSnapshot;
expect(messageMenuTarget(snapshot, "a")?.retryRowId).toBeUndefined();
expect(messageMenuTarget(snapshot, "b")?.retryRowId).toBe("b");
});
28 changes: 16 additions & 12 deletions webviews/src/agent-session/acpmux/conversation/PromptDelivery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { createContext, useContext, useEffect, useState } from "react";
import type { AcpmuxRow, PromptDelivery } from "../model";
import { useT, type StringKey } from "../i18n";
import { isTrustCause, sendCause, type FailedRun } from "../sendFailure";
import { isTrustCause, retryHelps, sendCause, type FailedRun } from "../sendFailure";
import { copyText } from "./clipboard";
import { Clock } from "./icons";

Expand Down Expand Up @@ -97,7 +97,8 @@ export function DeliveryFailure({ row }: { row: AcpmuxRow }) {
if (!row.failed) return null;
const rowIds = sends?.runs.get(row.id)?.rowIds ?? [row.id];
if (rowIds[rowIds.length - 1] !== row.id) return null;
const trust = isTrustCause(sendCause(delivery));
const cause = sendCause(delivery);
const trust = isTrustCause(cause);
const retryWaits = trust ? sends?.trustBlock : undefined;
const fix = trust ? sends?.trustFolder : undefined;
const act = (name: string) => void window.cmuxAcpmuxActions?.[name]?.({ rowId: row.id, rowIds });
Expand All @@ -107,7 +108,7 @@ export function DeliveryFailure({ row }: { row: AcpmuxRow }) {
return (
<div
className="cv-user__status cv-user__status--failed"
data-failure-cause={sendCause(delivery)}
data-failure-cause={cause}
data-failed-count={rowIds.length}
aria-live="polite"
>
Expand All @@ -120,15 +121,18 @@ export function DeliveryFailure({ row }: { row: AcpmuxRow }) {
{t("trust.trustFolder")}
</button>
)}
<button
type="button"
className="cv-user__cancel"
disabled={retryWaits !== undefined}
title={retryWaits && t(retryWaits)}
onClick={() => act("chat.retryPrompt")}
>
{t("turn.retry")}
</button>
{/* A prompt too large to send is refused the same way again: Edit takes an attachment out. */}
{retryHelps(cause) && (
<button
type="button"
className="cv-user__cancel"
disabled={retryWaits !== undefined}
title={retryWaits && t(retryWaits)}
onClick={() => act("chat.retryPrompt")}
>
{t("turn.retry")}
</button>
)}
{delivery && (
<>
<button type="button" className="cv-user__cancel" onClick={() => act("chat.editFailedPrompt")}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Lexer, lexer, type Token, walkTokens } from "marked";
import { turnRows } from "../diff";
import type { AcpmuxSnapshot } from "../model";
import { menuPartAt, type MenuPart } from "./menuParts";
import { retryHelps, sendCause } from "../sendFailure";

export const MESSAGE_MENU_HANDLER = "cmuxAgentContextMenu";

Expand Down Expand Up @@ -140,7 +141,8 @@ export function messageMenuTarget(
row.kind === "user" ? { text: row.text } : { text: plainText(row.text), markdown: row.text };
if (forkSeq !== undefined) target.forkSeq = forkSeq;
if (forkSeq !== undefined && sideChat) target.sideChat = true;
if (row.kind === "user" && row.failed) target.retryRowId = row.id;
// A prompt too large to send is refused the same way again: no Retry, Edit takes an attachment out.
if (row.kind === "user" && row.failed && retryHelps(sendCause(row.delivery))) target.retryRowId = row.id;
const links = webLinks(row.text);
if (links.length > 0) target.links = links;
return target;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2794,6 +2794,33 @@ export default agentPaneEntry({
);
},
},
"failed-send-too-large": {
note: 'A prompt larger than one message (message.too_large): acpmux refuses it the same way at every Retry, so the card names the attachment to take out and offers Edit, Remove and Details, never Retry (Leo, 2026-10-31: "this is completely hardlocked bc of message size").',
snapshot: chat([
user(prompt, 6),
assistant("Added retries with backoff.", 5.5),
summary(5.5, { status: "completed" }),
user("Why does the dashboard look like this on the staging build?", 1, {
failed: true,
error:
"Not sent: this message is too large to send at once. Take out dashboard-recording.png with Edit, or send it in a message of its own.",
delivery: {
state: "failed",
promptId: "p1",
sessionId: "s",
sentAt: 0,
failedAt: 900,
hop: "acpmux",
code: "-32000",
reason: "message.too_large",
error: "message too large to send: 16907300 bytes, the limit is 15728640",
},
}),
]),
play: async (ctx) => {
await ctx.waitFor(() => ctx.document.querySelector('[data-failure-cause="too-large"]'));
},
},
"edited-files": {
note: "A turn that edited three files: the edited-files card with Undo.",
snapshot: chat([...workedTurnRows(minutesAgo(15)), summary(14, { status: "completed", toolCount: 3 })]),
Expand Down
12 changes: 8 additions & 4 deletions webviews/src/agent-session/acpmux/direct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ import { cliHost, incomingPrompt, type IncomingContext, type IncomingMessage } f
import { startedCardsShown } from "./started/startedSessions";
import { OfferChildren } from "./offer/offerChildren";
import { errorMessage } from "./transportErrors";
import { failedSendText } from "./sendFailure";
import { failedSendText, retryHelps, sendCause } from "./sendFailure";
import { SessionPager, type SessionPage } from "./sessionPages";
import { isWarmableCwd } from "./warmFolders";
import { isPlumbingCall, showsNothing } from "./hiddenTools";
Expand Down Expand Up @@ -6073,7 +6073,9 @@ export class AcpmuxDirectClient {
// says just that, not the generic refusal's pick wording (cx-w2p8r).
if (held && !isTrustRefusal(error))
this.notice(
code === "transport.gesture_required" ? translate("prompt.notSentAgain") : failedSendText(error, false),
code === "transport.gesture_required"
? translate("prompt.notSentAgain")
: failedSendText(error, false, sendAttachments),
);
else this.emit();
throw error;
Expand Down Expand Up @@ -6103,7 +6105,7 @@ export class AcpmuxDirectClient {
...row,
pending: false,
failed: true,
error: failedSendText(error, this.retriedPrompts.has(promptId)),
error: failedSendText(error, this.retriedPrompts.has(promptId), sendAttachments),
version: row.version + 1,
});
this.failedPrompts.set(rowId, { input, attachments, promptId });
Expand Down Expand Up @@ -6230,7 +6232,9 @@ export class AcpmuxDirectClient {
/// prompt id (a trust refusal's kept gesture is bound to that id, heldPrompt.ts). The run's other
/// rows go.
async retryPrompts(rowIds: readonly string[], ticket?: string, anchor?: string): Promise<string | undefined> {
const run = rowIds.filter((id) => this.failedPrompts.has(id));
// A prompt too large to send is refused the same way again, from any Retry (the card, the
// message menu): it stays failed until Edit takes an attachment out.
const run = rowIds.filter((id) => this.failedPrompts.has(id) && retryHelps(sendCause(this.rows.get(id)?.delivery)));
const kept = anchor && run.includes(anchor) ? anchor : run[run.length - 1];
if (!kept) return undefined;
if (run.length > 1) {
Expand Down
14 changes: 14 additions & 0 deletions webviews/src/agent-session/acpmux/hostRetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
HOST_ERROR_DEADLINE_MS,
hostErrorShows,
hostIsReconnecting,
hostRefusedTooLarge,
nextRetryDelay,
offersAcpmuxRestart,
} from "./hostRetry";
Expand Down Expand Up @@ -171,3 +172,16 @@ test("a recovering pane asks again within 2 s; a refusal backs off to 30 s", ()
expect(waits(true)).toEqual([250, 500, 1000, 2000, 2000, 2000, 2000, 2000]);
expect(Math.max(...waits(false))).toBe(30_000);
});

/// A chat whose open reply passes one message is refused the same way at every ask (Leo,
/// 2026-10-31: "this is completely hardlocked bc of message size"): the pane stops asking on its own.
test("a handshake refused as message.too_large is told apart from every other refusal", () => {
const tooLarge = { code: -32000, reason: "message.too_large", message: "message too large to send: 16907300 bytes" };
expect(hostRefusedTooLarge(tooLarge)).toBe(true);
// Said at once, as any refusal: no waiting out the recovery deadline first.
expect(hostErrorShows(tooLarge, 0)).toBe(true);
expect(hostRefusedTooLarge({ code: "native.timed_out" })).toBe(false);
expect(hostRefusedTooLarge({ code: -32000, reason: "trust.pending" })).toBe(false);
expect(hostRefusedTooLarge(new Error("message too large to send"))).toBe(false);
expect(hostRefusedTooLarge(null)).toBe(false);
});
8 changes: 8 additions & 0 deletions webviews/src/agent-session/acpmux/hostRetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export function hostIsReconnecting(error: unknown): boolean {
return fields?.code === undefined;
}

/// A handshake refused because a reply would pass one message (`message.too_large`: a chat
/// whose records together were too large to open, on an acpmux before #28754). Asking again gets
/// the same reply, so the pane never asks on its own; the line's Retry still asks (Leo,
/// 2026-10-31: "this is completely hardlocked bc of message size").
export function hostRefusedTooLarge(error: unknown): boolean {
return (error as { reason?: unknown } | null)?.reason === "message.too_large";
}

/// How long a pane's handshakes may keep failing while the host is still recovering (an update's
/// handoff, a daemon starting or restarting, a start that lost the lock race) before the pane says
/// so. Until then the pane shows nothing: no card, no header status, no button (Leo, 2026-10-29:
Expand Down
34 changes: 31 additions & 3 deletions webviews/src/agent-session/acpmux/sendFailure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
// prompt's delivery), never from its words; the words and the code stay for Details.
import { translate } from "./i18n";
import { DATE } from "./conversation/turns";
import type { ComposerAttachment } from "./attachments";
import type { AcpmuxRow } from "./model";
import { errorMessage } from "./transportErrors";

/// The causes the failure card names in its own words, with their own way out.
export type SendCause = "trust-pending" | "trust-untrusted" | "pane" | "other";
export type SendCause = "trust-pending" | "trust-untrusted" | "pane" | "too-large" | "other";

type CauseFields = { code?: unknown; reason?: unknown } | null | undefined;

Expand All @@ -21,6 +22,8 @@ export function sendCause(fields: CauseFields): SendCause {
// The reasons acpmux's trust gate writes (trust_gate.rs), as isTrustRefusal reads them.
if (reason === "trust.pending") return "trust-pending";
if (reason === "trust.untrusted") return "trust-untrusted";
// More than one message carries (`rpc::MAX_MESSAGE_BYTES`): the same prompt is refused again.
if (reason === "message.too_large") return "too-large";
// A request the pane's host would not take is the pane's bug, not something the person did.
if (text(fields?.code) === "transport.intent_invalid") return "pane";
return "other";
Expand All @@ -29,9 +32,32 @@ export function sendCause(fields: CauseFields): SendCause {
/// A trust cause: Trust folder fixes it, and Retry waits while the folder's question is open.
export const isTrustCause = (cause: SendCause) => cause === "trust-pending" || cause === "trust-untrusted";

/// Whether Retry can help: a prompt too large to send is refused the same way again, so its way
/// out is Edit (Leo, 2026-10-31: "this is completely hardlocked bc of message size").
export const retryHelps = (cause: SendCause) => cause !== "too-large";

/// The bytes an attachment puts in the prompt: its base64 or its text.
const promptBytes = (attachment: ComposerAttachment) => (attachment.data?.length ?? 0) + (attachment.text?.length ?? 0);

/// A prompt too large to send: the attachment that takes the most of it, by name, when one does.
function tooLargeText(attachments: readonly ComposerAttachment[]): string {
const largest = attachments.reduce<ComposerAttachment | undefined>(
(most, attachment) => (promptBytes(attachment) > (most ? promptBytes(most) : 0) ? attachment : most),
undefined,
);
return largest
? translate("prompt.failure.tooLargeFile", { name: largest.name })
: translate("prompt.failure.tooLarge");
}

/// The failed prompt's one sentence. `retried`: a Retry that was refused again says "Still"
/// (hq#2569 item 5), so the click visibly did something.
export function failedSendText(error: unknown, retried: boolean): string {
/// (hq#2569 item 5), so the click visibly did something. `attachments`: the prompt's, so a prompt
/// too large to send names the one to take out.
export function failedSendText(
error: unknown,
retried: boolean,
attachments: readonly ComposerAttachment[] = [],
): string {
const fields = error && typeof error === "object" ? (error as { code?: unknown; reason?: unknown }) : undefined;
switch (sendCause(fields)) {
case "trust-pending":
Expand All @@ -42,6 +68,8 @@ export function failedSendText(error: unknown, retried: boolean): string {
// The pane's bug, not the person's: logged for its report, as errorMessage logs it.
console.error("agent pane: the host refused a request", error);
return translate(retried ? "prompt.failure.paneAgain" : "prompt.failure.pane");
case "too-large":
return tooLargeText(attachments);
default:
break;
}
Expand Down
Loading