agent: give bots exactly one reply per message (#207)
processMsg had no idempotency gate — it replied to whatever message id it
was handed and only used the thread cursor advisorily (for catch-up's
`after`). So any duplicate delivery (a reconnect's catch-up overlapping a
live frame, or a socket that briefly double-delivered) produced a second
reply — the occasional double bot reply seen in DMs. Add a strict
`id > cursor` gate whose read+advance are synchronous, so a redelivered or
concurrently-delivered id is dropped instead of answered twice.
Also trim trailing assistant turns from the Claude context (new pure
context.mjs, unit-tested). With `thinking:{adaptive}` always on, a context
ending on an assistant turn is a hard "assistant message prefill" 400; that
happens whenever the newest visible message is our own reply (the same
redelivery race). Trimming both ends lets the request through.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 files changed,
+83
−5
agent/context.mjs+17 −0agent/runtime.mjs+15 −4changelog/2026-07-05-bot-double-reply.md+1 −0package.json+1 −1test/context.test.mjs+49 −0
diff --git a/agent/context.mjs b/agent/context.mjs| new file mode 100644 |
| index 0000000..6445495 |
| --- /dev/null |
| +++ b/agent/context.mjs |
| @@ -0,0 +1,17 @@ |
| +// Pure helper for shaping the Claude message list, split out of runtime.mjs so |
| +// it's exercisable in Node tests (test/context.test.mjs) without the SDK/ws deps. |
| + |
| +// trimContext drops assistant turns from BOTH ends of the message list. The API |
| +// requires the first turn to be `user`; and with extended thinking on (the |
| +// runtime always sends `thinking:{adaptive}`) the conversation also can't END on |
| +// an assistant turn — there's no prefill under thinking, so a trailing assistant |
| +// turn is a hard "assistant message prefill" 400. A trailing assistant turn shows |
| +// up whenever the newest message we can see is our own reply (e.g. a redelivery |
| +// race where a message got processed after we'd already answered); trimming it |
| +// lets the request through instead of erroring. Returns a new array. |
| +export function trimContext(messages) { |
| + const out = [...(messages || [])]; |
| + while (out.length && out[0].role === "assistant") out.shift(); |
| + while (out.length && out[out.length - 1].role === "assistant") out.pop(); |
| + return out; |
| +} |
diff --git a/agent/runtime.mjs b/agent/runtime.mjs| index b09120b..d357e41 100644 |
| --- a/agent/runtime.mjs |
| +++ b/agent/runtime.mjs |
| @@ -11,6 +11,7 @@ import { importKey, encrypt, decrypt, unwrapThreadKey, openSealed, sealForPub } |
| import { BASE, WS_BASE, MODEL, CLASSIFIER_MODEL, APP_NAME, AI_ENABLED } from "./config.mjs"; |
| import { actionsFromToolUse } from "./actions.mjs"; |
| import { runCommand } from "./commands.mjs"; |
| +import { trimContext } from "./context.mjs"; |
| import { listDomains, exchangeViaBroker } from "./dnsimple.mjs"; |
| const log = (m) => { |
| @@ -222,9 +223,10 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t |
| content += `\n[note to self: this reply attached ${p.actions.length} button(s) via tool calls]`; |
| out.push({ role: mine ? "assistant" : "user", content }); |
| } |
| - // The API requires the first turn to be `user`. |
| - while (out.length && out[0].role === "assistant") out.shift(); |
| - return out; |
| + // Drop assistant turns from both ends (see trimContext): a leading one breaks |
| + // the API's "first turn must be user" rule, and a trailing one is a hard 400 |
| + // under thinking mode. |
| + return trimContext(out); |
| } |
| async function reply(threadId, entry) { |
| @@ -467,7 +469,16 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t |
| async function processMsg(threadId, id, blob) { |
| const entry = keys.get(threadId); |
| if (!entry) return; |
| - cursors.set(threadId, Math.max(cursors.get(threadId) || 0, id || 0)); |
| + // Idempotency gate. Message ids are monotonic and we only ever act on ones |
| + // strictly newer than the highest we've seen on this thread, so a message |
| + // delivered more than once — a reconnect's catch-up overlapping a live frame, |
| + // or a socket that briefly double-delivered — is dropped here instead of |
| + // answered twice (the cause of the occasional double bot reply). The read + |
| + // advance are synchronous (no await between) so two concurrent deliveries of |
| + // the same id can't both slip past. |
| + const seen = cursors.get(threadId) || 0; |
| + if (id && id <= seen) return; |
| + cursors.set(threadId, id || seen); |
| let p; |
| try { |
| p = await decrypt(entry.key, blob); |
diff --git a/changelog/2026-07-05-bot-double-reply.md b/changelog/2026-07-05-bot-double-reply.md| new file mode 100644 |
| index 0000000..f11d09a |
| --- /dev/null |
| +++ b/changelog/2026-07-05-bot-double-reply.md |
| @@ -0,0 +1 @@ |
| +Fixed a hiccup where an AI helper (like Claude) could occasionally answer the same message twice, or briefly go quiet on a reply. Helpers now give exactly one reply per message, even across a brief reconnect. |
diff --git a/package.json b/package.json| index b780766..ebf65f6 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/apps.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node home/test/crypto.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/apps.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node home/test/crypto.test.mjs" |
| } |
| } |
diff --git a/test/context.test.mjs b/test/context.test.mjs| new file mode 100644 |
| index 0000000..85673ab |
| --- /dev/null |
| +++ b/test/context.test.mjs |
| @@ -0,0 +1,49 @@ |
| +// Tests for the Claude context shaper. node test/context.test.mjs |
| +import assert from "node:assert/strict"; |
| +import { trimContext } from "../agent/context.mjs"; |
| + |
| +let failures = 0; |
| +function test(name, fn) { |
| + try { |
| + fn(); |
| + console.log(`PASS ${name}`); |
| + } catch (e) { |
| + failures++; |
| + console.log(`FAIL ${name} — ${e && e.message}`); |
| + } |
| +} |
| + |
| +const u = (content = "hi") => ({ role: "user", content }); |
| +const a = (content = "hi") => ({ role: "assistant", content }); |
| + |
| +test("drops a leading assistant turn (API needs user first)", () => { |
| + assert.deepEqual(trimContext([a("greeting"), u("q"), a("ans")]), [u("q")]); |
| +}); |
| + |
| +test("drops a trailing assistant turn (no prefill under thinking)", () => { |
| + // The 400 case: newest message we can see is our own reply. |
| + assert.deepEqual(trimContext([u("q"), a("ans")]), [u("q")]); |
| +}); |
| + |
| +test("keeps a conversation that already ends on a user turn", () => { |
| + const c = [u("a"), a("b"), u("c")]; |
| + assert.deepEqual(trimContext(c), c); |
| +}); |
| + |
| +test("all-assistant collapses to empty (caller falls back to a hello)", () => { |
| + assert.deepEqual(trimContext([a(), a()]), []); |
| +}); |
| + |
| +test("trims both ends at once", () => { |
| + assert.deepEqual(trimContext([a(), u("x"), a("y"), u("z"), a("w")]), [u("x"), a("y"), u("z")]); |
| +}); |
| + |
| +test("empty / nullish input is safe and returns a fresh array", () => { |
| + assert.deepEqual(trimContext([]), []); |
| + assert.deepEqual(trimContext(null), []); |
| + const src = [u("x")]; |
| + assert.notEqual(trimContext(src), src); // never mutates or aliases the caller's array |
| +}); |
| + |
| +console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| +process.exit(failures === 0 ? 0 : 1); |