From 41b5c508df9b939bf9fa8838b946bfd9d5e265ac Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 11 Sep 2026 17:43:34 -0400 Subject: [PATCH] easel: a refused turn has to say so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two sessions running at once both stopped with nothing on screen but `interrupted`. The reason was there and was being thrown away: a usage cap or a rate limit arrives as a synthetic assistant message, `#assistant` dropped it on `is_api_error_message`, and the result that follows reports `interrupted` — a status that carries no error by design. So the one sentence explaining the stop reached nobody. It is emitted as an error now, and the turn still reports how it ended. Co-Authored-By: Claude Opus 5 (1M context) --- easel/src/claude-server.mjs | 25 ++++++++++++++++++++++--- easel/test/claude-server.test.mjs | 25 +++++++++++++++++++++++++ easel/test/fake-claude-cli.mjs | 13 +++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/easel/src/claude-server.mjs b/easel/src/claude-server.mjs index 5a5f5b2504..9476f5b29e 100644 --- a/easel/src/claude-server.mjs +++ b/easel/src/claude-server.mjs @@ -410,9 +410,28 @@ export class ClaudeServer extends EventEmitter { #assistant(message) { const content = message.message?.content || []; - // A rate limit or a refused model comes back as a synthetic assistant - // message with no stream behind it. It reads as an error, not as an answer. - if (message.is_api_error_message) return; + // A rate limit, a usage cap, or a refused model comes back as a synthetic + // assistant message with no stream behind it. It reads as an error, not as + // an answer — and it is the only explanation there will be: the turn that + // follows reports `interrupted`, and an interrupted turn carries no error. + // Dropping it left a session that simply stopped, for no stated reason. + if (message.is_api_error_message) { + const text = content + .filter((block) => block.type === "text" && block.text) + .map((block) => block.text) + .join(" ") + .trim(); + this.emit("notification", { + method: "error", + params: { + error: { message: text || "the engine refused the turn" }, + // The turn's own result decides whether the session is done for; this + // is the reason, not the verdict. + willRetry: true, + }, + }); + return; + } let index = 0; for (const block of content) { if (block.type === "tool_use") { diff --git a/easel/test/claude-server.test.mjs b/easel/test/claude-server.test.mjs index 3f38c7bcc2..4da73ba838 100644 --- a/easel/test/claude-server.test.mjs +++ b/easel/test/claude-server.test.mjs @@ -188,3 +188,28 @@ test("an interrupted turn reads as interrupted, not as a failure", async (t) => assert.equal(turn.status, "interrupted"); assert.equal(turn.error, undefined); }); + +// A turn that the API refuses reports `interrupted`, and an interrupted turn +// carries no error — so if the refusal itself is dropped, the session simply +// stops for no stated reason. That is what two parallel sessions looked like. +test("a refused turn says why, instead of stopping in silence", async (t) => { + const engine = bridge(t, { + environment: { FAKE_CLAUDE_API_ERROR: "Usage limit reached. Try again at 6pm." }, + }); + + const errors = []; + const finished = new Promise((resolve) => { + engine.on("notification", ({ method, params }) => { + if (method === "error") errors.push(params.error?.message); + if (method === "turn/completed") resolve(params.turn); + }); + }); + + await engine.connect(); + await engine.startTurn("make a piece"); + const turn = await finished; + + assert.equal(turn.status, "interrupted", "the turn still reports how it ended"); + assert.deepEqual(errors, ["Usage limit reached. Try again at 6pm."], + "and the reason reaches the interface"); +}); diff --git a/easel/test/fake-claude-cli.mjs b/easel/test/fake-claude-cli.mjs index 3c9d7a611c..4fcb71948e 100644 --- a/easel/test/fake-claude-cli.mjs +++ b/easel/test/fake-claude-cli.mjs @@ -57,6 +57,19 @@ createInterface({ input: process.stdin }).on("line", (line) => { return; } + // A turn the API refuses outright: a usage cap or a rate limit arrives as a + // synthetic assistant message with no stream behind it, then an aborted + // result. This is the shape two parallel sessions hit. + if (message.type === "user" && process.env.FAKE_CLAUDE_API_ERROR) { + send({ + type: "assistant", + is_api_error_message: true, + message: { id: "msg_err", content: [{ type: "text", text: process.env.FAKE_CLAUDE_API_ERROR }] }, + }); + send({ type: "result", subtype: "error_during_execution", terminal_reason: "aborted_by_api" }); + return; + } + if (message.type === "user") { send({ type: "stream_event", event: { type: "message_start", message: { id: "msg_1" } } }); send({ type: "stream_event", event: { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } }); -- 2.51.2