eleven / ocho Public

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>
Paul Campbell ce9563d89960069affa335461cae92b6ff9b21e0 parent e64eb3e
5 files changed, +83 −5
  • agent/context.mjs +17 −0
  • agent/runtime.mjs +15 −4
  • changelog/2026-07-05-bot-double-reply.md +1 −0
  • package.json +1 −1
  • test/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);