From 842e98c070809f2ee0c8d847862b4c697e064d9f Mon Sep 17 00:00:00 2001 From: Alin <41188223+al0x99@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:20:17 +0200 Subject: [PATCH] Preserve uncertain delivery when timeout messages are read-only --- .changeset/preserve-send-timeout.md | 5 +++ src/core/gateway.js | 16 ++++----- test/gateway-send.test.js | 55 +++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 .changeset/preserve-send-timeout.md diff --git a/.changeset/preserve-send-timeout.md b/.changeset/preserve-send-timeout.md new file mode 100644 index 0000000..1e5571f --- /dev/null +++ b/.changeset/preserve-send-timeout.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": patch +--- + +Preserve unknown delivery and the target when a prompt send times out with a DOMException. Keep the original exception as the annotated error's cause. diff --git a/src/core/gateway.js b/src/core/gateway.js index dcc9721..21c8157 100644 --- a/src/core/gateway.js +++ b/src/core/gateway.js @@ -448,14 +448,14 @@ export async function sendPrompt(session, ref, prompt, extra = {}) { } catch (err) { // Delivery states: the server answered no (rejected) vs the request may have landed (unknown). // Never retry an unknown delivery blindly; read the thread first. - if (err && err.delivery == null) { - err.delivery = err instanceof GatewayError && err.status != null && err.status < 500 ? "rejected" : "unknown"; - } - if (err && err.targetId == null) err.targetId = rec.id; - if (err && err.delivery === "unknown" && err instanceof Error && !/delivery unknown/.test(err.message)) { - err.message += " (delivery unknown; check the thread before resending)"; - } - throw err; + const delivery = err?.delivery ?? (err instanceof GatewayError && err.status != null && err.status < 500 ? "rejected" : "unknown"); + const error = delivery === "unknown" && err instanceof Error && !/delivery unknown/.test(err.message) + ? Object.assign(new Error(err.message, { cause: err }), err, { name: err.name }) + : err; + if (error && error.delivery == null) error.delivery = delivery; + if (error && error.targetId == null) error.targetId = rec.id; + if (error !== err) error.message += " (delivery unknown; check the thread before resending)"; + throw error; } const messageId = data && typeof data === "object" && typeof data.messageId === "string" ? data.messageId : null; // Only a confirmed receipt counts as accepted; anything else is unknown, never a silent accept. diff --git a/test/gateway-send.test.js b/test/gateway-send.test.js index fc0a0c9..ff1396c 100644 --- a/test/gateway-send.test.js +++ b/test/gateway-send.test.js @@ -73,6 +73,38 @@ test("sendPrompt rejects on a 4xx and preserves the target id", async (t) => { }); }); +test("sendPrompt rejects a cancelled preflight without fetching", async (t) => { + const controller = new AbortController(); + controller.abort(); + let received = 0; + t.mock.method(globalThis, "fetch", async () => { + received++; + throw new Error("Unexpected request after cancellation"); + }); + await assert.rejects(sendPrompt(session, "General", "hi", { signal: controller.signal }), (error) => { + assert.equal(error.delivery, "rejected"); + assert.equal(error.reason, "cancelled"); + return true; + }); + assert.equal(received, 0); +}); + +test("sendPrompt preserves a resolution error before prompt transmission", async (t) => { + const failure = new DOMException("The operation was aborted due to timeout", "TimeoutError"); + const original = Object.getOwnPropertyDescriptors(failure); + const received = []; + t.mock.method(globalThis, "fetch", async (url) => { + received.push(new URL(url).pathname); + throw failure; + }); + await assert.rejects(sendPrompt(session, "General", "hi"), (error) => { + assert.equal(error, failure); + return true; + }); + assert.deepEqual(received, ["/api/listAgents"]); + assert.deepEqual(Object.getOwnPropertyDescriptors(failure), original); +}); + test("sendPrompt marks transport loss unknown with a no-retry hint", async (t) => { t.mock.method(globalThis, "fetch", async (url) => { if (String(url).endsWith("/api/listAgents")) return new Response(JSON.stringify(roster), { status: 200 }); @@ -85,6 +117,29 @@ test("sendPrompt marks transport loss unknown with a no-retry hint", async (t) = }); }); +test("sendPrompt preserves an unknown timeout without mutating its DOMException", async (t) => { + const failure = new DOMException("The operation was aborted due to timeout", "TimeoutError"); + const original = Object.getOwnPropertyDescriptors(failure); + const received = []; + t.mock.method(globalThis, "fetch", async (url) => { + received.push(new URL(url).pathname); + if (String(url).endsWith("/api/listAgents")) return new Response(JSON.stringify(roster), { status: 200 }); + throw failure; + }); + + await assert.rejects(sendPrompt(session, "General", "hi"), (error) => { + assert.equal(error.delivery, "unknown"); + assert.equal(error.targetId, "bot-1"); + assert.equal(error.name, "TimeoutError"); + assert.equal(error.cause, failure); + assert.match(error.message, /delivery unknown; check the thread before resending/); + return true; + }); + assert.deepEqual(received, ["/api/listAgents", "/api/sendPrompt"]); + assert.equal(failure.message, "The operation was aborted due to timeout"); + assert.deepEqual(Object.getOwnPropertyDescriptors(failure), original); +}); + test("oversized gateway responses abort mid-stream instead of buffering", async (t) => { mockGateway(t, () => new Response("x".repeat(GATEWAY_MAX_RESPONSE_BYTES + 8), { status: 200 })); await assert.rejects(sendPrompt(session, "General", "hi"), /too large/);