fix(ai): a summary no longer reports a lost connection at its first heartbeat

Every summary that took more than ten seconds to start writing ended with
"The connection to plMail was lost … the model host is not involved", ten
seconds in, while the server went on to finish and store it: reload and it
was there.

The connection was fine. The first heartbeat frame renders the elapsed time,
and it called this.constructor.#clock(). Stimulus registers a subclass of
every controller, so this.constructor is that subclass, and a private static
is not reachable through it: TypeError "Receiver must be class default". The
fetch's catch took every error for a network error and said so. Broken since
the heartbeat arrived on 2026-09-03; the "connection dropped 40 s into a full
run" measured that day was very likely this too.

- #clock is an instance method.
- readFrames() no longer lets a throwing frame handler end the read. The
  error goes to reportError(), and so to the admin browser-errors list with
  its file and line, and the frame is skipped. Both readers put a failed read
  down to the network, which is what hid this one.
- The reading line said "Nothing appears until it has finished", which reads
  as "until the summary is finished". It meant the reading: the words stream
  as they are written, and the line now says so. EN, DE, en_PI.

The new browser test serves the server's frames without a model: a heartbeat
before the first word must neither end the run nor throw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-09-25 13:52:40 +02:00
1 parent 51bf4c2ca1
commit 77fa3f496d
6 files changed
+82 -12

No files matched your search

+15 -2
View File
@@ -36,7 +36,8 @@
* controller afterwards.
*
* @param {ReadableStream<Uint8Array>} body `response.body`
* @param {(frame: object) => void} onFrame called once per parsed line
* @param {(frame: object) => void} onFrame called once per parsed line; a
* throw is reported and costs that frame, not the stream
* @param {() => boolean} isCurrent asked after every read; false
* abandons the stream. An aborted fetch settles asynchronously, so
* without this the PREVIOUS run's frames keep arriving into a surface
@@ -80,7 +81,19 @@ export async function readFrames(body, onFrame, isCurrent) {
continue;
}
onFrame(frame);
// A handler that throws is the page's fault, not the stream's, and
// must not end the read: both callers put a failed read down to
// the host or the network, and the summary card says "the
// connection was lost" in as many words. That is how its own
// TypeError on the first heartbeat passed for a network fault for
// three weeks. Reported where plMail's own browser errors go
// (client_errors.js), and the frame is skipped like the truncated
// line above.
try {
onFrame(frame);
} catch (error) {
reportError(error);
}
}
}
}
@@ -504,9 +504,7 @@ export default class extends Controller {
// Only while nothing has been written yet: once tokens arrive, the
// text IS the progress and a timer beside it is furniture.
if (0 === this.#tokens) {
this.#say(
this.readingLabelValue.replace("%elapsed%", this.constructor.#clock(frame.elapsed)),
);
this.#say(this.readingLabelValue.replace("%elapsed%", this.#clock(frame.elapsed)));
}
return;
@@ -758,11 +756,17 @@ export default class extends Controller {
/**
* Seconds as m:ss.
*
* Static and tiny, because the alternative is Intl.RelativeTimeFormat
* saying "4 minutes ago" about something that is happening now, or a raw
* second count that reads as an error code once it passes 200.
* Tiny, because the alternative is Intl.RelativeTimeFormat saying "4
* minutes ago" about something that is happening now, or a raw second
* count that reads as an error code once it passes 200.
*
* NOT STATIC, though it uses nothing of the instance. Stimulus registers a
* subclass of this class, so `this.constructor` is that subclass, and a
* private static cannot be reached through it: `this.constructor.#clock`
* threw "Receiver must be class default" on the first heartbeat of every
* run slow enough to get one, and the card said the connection was lost.
*/
static #clock(seconds) {
#clock(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0));
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
+53
View File
@@ -43,6 +43,7 @@ import { settled } from "./support/motion";
const OFFER = '[data-mail--thread-summary-target="run"]';
const STOP = '[data-mail--thread-summary-target="stop"]';
const STATUS = '[data-mail--thread-summary-target="status"]';
const OUTPUT = '[data-mail--thread-summary-target="output"]';
const CARD = "[data-thread-summary]";
test.beforeAll(() => {
@@ -116,4 +117,56 @@ test.describe("thread summary", () => {
// the surface they asked for, not at it disappearing under them.
await expect(card).toBeVisible();
});
/**
* The run that is silent for longer than OllamaClient's ten-second
* heartbeat — which is every cold one and most long threads.
*
* The dead address above never gets this far, and that is how this went
* unseen: the card threw on the first `ping` frame and reported its own
* TypeError as "the connection to plMail was lost", ten seconds in, on a
* run the server went on to finish and store.
*
* The frames are the server's, served without a model. `pageerror` is
* collected as well as the outcome, because a frame the card cannot apply
* is now reported and skipped rather than fatal — so the answer arriving
* no longer proves the heartbeat was handled, and the error would.
*/
test("a heartbeat before the first word does not end the run", async ({ page }) => {
seed("seed-conversation");
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
// The POST only: the GET reads the store, and nothing here stores.
await page.route(/\/mail\/thread\/\d+\/summary$/, (route) => {
if ("POST" !== route.request().method()) {
return route.continue();
}
return route.fulfill({
status: 200,
contentType: "application/x-ndjson",
body: [
{ type: "state", value: "waiting" },
{ type: "ping", elapsed: 10 },
{ type: "token", text: "First point" },
{ type: "done", text: "First point\nSecond point", partial: false, full: false },
].map((frame) => JSON.stringify(frame) + "\n").join(""),
});
});
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/mail/inbox");
await settled(page);
await mailRow(page, "E2E Conversation").click();
await page.locator(OFFER).click();
await expect(page.locator(OUTPUT)).toContainText("Second point");
// Hidden is what a finished run looks like: the summary explains itself.
await expect(page.locator(STATUS)).toBeHidden();
expect(errors).toEqual([]);
});
});
+1 -1
View File
@@ -3715,7 +3715,7 @@ thread:
stopped: 'Angehalten. Nichts wurde gespeichert.'
waiting: 'Die erste Anfrage nach einer Weile dauert etwa eine Minute, solange das Modell lädt und das Gespräch liest.'
queued: 'In der Warteschlange. Das ganze Gespräch wird im Hintergrund zusammengefasst — du kannst das hier zumachen und später wiederkommen, dann ist es da.'
reading: 'Liest das Gespräch noch — %elapsed% bisher. Bis es fertig ist, passiert sichtbar nichts.'
reading: 'Liest das Gespräch noch — %elapsed% bisher. Danach erscheint die Zusammenfassung Wort für Wort.'
waiting_full: 'Liest das ganze Gespräch. Bei einem langen Verlauf kann das mehrere Minuten dauern, und bis es fertig gelesen hat, passiert sichtbar nichts.'
working: 'Fasst zusammen …'
stop: 'Anhalten'
+1 -1
View File
@@ -3702,7 +3702,7 @@ thread:
stopped: 'Stopped. Nothing was saved.'
waiting: 'The first request after a while takes about a minute while the model loads and reads the conversation.'
queued: 'Queued. The whole conversation is being summarised in the background — you can close this and come back; it will be here.'
reading: 'Still reading the conversation — %elapsed% so far. Nothing appears until it has finished.'
reading: 'Still reading the conversation — %elapsed% so far. After that, the summary appears word by word.'
waiting_full: 'Reading the whole conversation. This can take several minutes on a long thread, and nothing appears until it has finished reading.'
working: 'Summarising…'
stop: 'Stop'
+1 -1
View File
@@ -3506,7 +3506,7 @@ thread:
stopped: 'Belayed. Nowt were kept.'
waiting: "Th' first askin' after a spell takes near a minute while th' oracle wakes an' reads th' yarn."
queued: "In th' queue. Th' whole yarn be boiled down below decks — ye may close this an' come back; it be waitin'."
reading: "Still readin' th' yarn — %elapsed% so far. Nowt shows till she be done."
reading: "Still readin' th' yarn — %elapsed% so far. Then she be writ out word by word."
waiting_full: "Readin' th' whole yarn. On a long one that be several minutes, an' nowt shows till she has read it through."
working: "Boilin' her down…"
stop: 'Belay'