Let bots take in messages sent while they reply

A reply read the session once at the start, so a message sent while it
ran was never seen, and the follow-up reply queued for it then finished
silently: the message was saved before the reply, so the thread ended
with the bot. Those messages went unanswered.

The text loop now reads new user messages after every round of tools and
before an answer goes out, and the bot decides from what each one means
whether it changes the work (folded in, or the unsent answer redrafted)
or is a separate request, which it acknowledges at once with the new
send_message tool and does after the current work. Messages sent that
way are marked interim, so the thinking row stays up while the bot
works. A reply job records the newest user message it read, and a queued
reply only stays silent when a finished reply already read everything.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 04:49:10 +01:00
co-authored by Claude Opus 5.5
parent c13b9ea826
commit b0fe619ae8
25 changed files with 754 additions and 20 deletions
@@ -0,0 +1,46 @@
import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import answeredMessageIdSql from "./answered-message-id-sql";
/**
* What decides whether a queued reply still has something to answer, run
* against real SQLite: only finished replies in the same session count, so
* a failed or still running one never hides a message.
*/
const db = new Database(":memory:");
db.run(
`CREATE TABLE bots_queue (id INTEGER PRIMARY KEY, session_id INTEGER, kind TEXT, status TEXT, message_id INTEGER)`,
);
db.run(`INSERT INTO bots_queue VALUES
(1, 100, 'reply', 'completed', 10),
(2, 100, 'reply', 'completed', 14),
(3, 100, 'reply', 'processing', 20),
(4, 100, 'reply', 'failed', 22),
(5, 100, 'task', 'completed', 30),
(6, 101, 'reply', 'completed', 40),
(7, 102, 'reply', 'pending', 50)`);
function answered(session_id: number) {
const { sql, values } = answeredMessageIdSql({ session_id });
return (db.query(sql).get(...values) as { id: number | null }).id;
}
describe("answeredMessageIdSql", () => {
test("is the newest message a finished reply in the session read", () => {
expect(answered(100)).toBe(14);
expect(answered(101)).toBe(40);
});
test("ignores running and failed replies, and tasks", () => {
expect(answered(100)).not.toBe(20);
expect(answered(100)).not.toBe(22);
expect(answered(100)).not.toBe(30);
});
test("is null where no reply has finished", () => {
expect(answered(102)).toBe(null);
expect(answered(999)).toBe(null);
});
});
@@ -0,0 +1,20 @@
import type { SqlCondition } from "@/src/utils/crew-access/types";
type Params = {
session_id: number | string;
};
/**
* The SQL for the newest user message a finished reply in the session
* answered: a reply job's `message_id` is the newest one it read
* (`markReplyAnswered`), so a message with a higher id came in after the
* bot last looked and still needs an answer, wherever its reply landed in
* the thread. Selects one `id` column, null when no reply has finished.
*/
export default function answeredMessageIdSql({ session_id }: Params): SqlCondition {
return {
sql: `SELECT MAX(message_id) AS id FROM bots_queue
WHERE session_id = ? AND kind = 'reply' AND status = 'completed'`,
values: [Number(session_id)],
};
}
@@ -0,0 +1,19 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import answeredMessageIdSql from "./answered-message-id-sql";
type Params = {
session_id: number | string;
};
/**
* The newest user message a finished reply in the session answered
* (`answeredMessageIdSql`), or null when none is on record.
*/
export default async function grabAnsweredMessageId({ session_id }: Params) {
const { sql, values } = answeredMessageIdSql({ session_id });
const res = await BunSQLite.sql<{ id: number | null }>({ sql, values });
const id = res.payload?.[0]?.id;
return id ? Number(id) : null;
}
@@ -0,0 +1,22 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
job_id: number | string;
/**
* The newest user message the reply read.
*/
message_id: number;
};
/**
* Records the newest user message a running reply read, so a reply queued
* for a message it already took in finishes silently and one it missed is
* still answered (`grabAnsweredMessageId`). Never lowers it.
*/
export default async function markReplyAnswered({ job_id, message_id }: Params) {
await BunSQLite.sql({
sql: `UPDATE bots_queue SET message_id = ?
WHERE id = ? AND status = 'processing' AND (message_id IS NULL OR message_id < ?)`,
values: [message_id, job_id, message_id],
});
}
@@ -14,6 +14,15 @@ type Params = {
* Only messages older than this one, for loading further back.
*/
before_id?: number | null;
/**
* Only messages saved after this one (by id, which is insertion
* order), for what arrived while a reply ran.
*/
after_id?: number | null;
/**
* Only one side's messages.
*/
author?: "user" | "bot";
/**
* Whose conversation it is, so a reaction can show as the user's own.
* Left out for the bot's own history reads, which don't need it.
@@ -30,9 +39,12 @@ export default async function grabBotMessages({
session_id,
limit = 100,
before_id,
after_id,
author,
user_id,
}: Params): Promise<BotMessageWithAttachments[]> {
let sql = ``;
const values: (string | number)[] = [session_id];
sql += `SELECT * FROM (\n`;
sql += ` SELECT * FROM bot_messages\n`;
@@ -40,6 +52,17 @@ export default async function grabBotMessages({
if (before_id) {
sql += ` AND (created_at, id) < (SELECT created_at, id FROM bot_messages WHERE id = ?)\n`;
values.push(before_id);
}
if (after_id) {
sql += ` AND id > ?\n`;
values.push(after_id);
}
if (author) {
sql += ` AND author = ?\n`;
values.push(author);
}
sql += ` ORDER BY created_at DESC, id DESC\n`;
@@ -48,7 +71,7 @@ export default async function grabBotMessages({
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
sql,
values: before_id ? [session_id, before_id, limit] : [session_id, limit],
values: [...values, limit],
});
const messages = res.payload || [];
@@ -13,6 +13,10 @@ type Params = {
workspace_id?: number | null;
text: string;
needs_you?: boolean;
/**
* Sent mid-reply (`send_message`): the reply is still being worked on.
*/
interim?: boolean;
/**
* Files the bot saved and sent with it (`media` ids): a PDF it made, a
* generated image, a document it wrote.
@@ -30,10 +34,12 @@ export default async function insertBotMessage({
workspace_id,
text,
needs_you,
interim,
media_ids = [],
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
const now = Date.now();
const needs_you_flag = needs_you ? 1 : 0;
const interim_flag = interim ? 1 : 0;
const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_BOT_MESSAGES,
@@ -48,6 +54,7 @@ export default async function insertBotMessage({
author: "bot",
text,
needs_you: needs_you_flag,
interim: interim_flag,
},
],
});
@@ -78,6 +85,7 @@ export default async function insertBotMessage({
author: "bot",
text,
needs_you: needs_you_flag,
interim: interim_flag,
created_at: now,
updated_at: now,
};