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: "" } } });