mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
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:
1 parent
51bf4c2ca1
commit
77fa3f496d
6 files changed
+82
-12
No files matched your search
+15
-2
@@ -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")}`;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user