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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user