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
@@ -1,4 +1,5 @@
import { useMemo, type RefObject } from "react";
import isAwaitingReply from "../(utils)/is-awaiting-reply";
import Stack from "@moduletrace/twui/layout/Stack";
import type {
BotMessageWithAttachments,
@@ -103,9 +104,10 @@ export default function MessageThread({
bot={bot}
progress={progress}
reply_progress={reply_progress}
awaiting_reply={
messages[messages.length - 1]?.author == "user"
}
awaiting_reply={isAwaitingReply({
last_message: messages[messages.length - 1],
face: bot.face,
})}
receiving={receiving}
has_task={has_task}
/>
@@ -1,4 +1,5 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import isAwaitingReply from "../(utils)/is-awaiting-reply";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
import type {
@@ -160,7 +161,7 @@ export default function Conversation() {
const last_saved_id = messages[messages.length - 1]?.id || null;
const last_thread_message = thread_messages[thread_messages.length - 1];
const awaiting_reply = last_thread_message?.author == "user";
const awaiting_reply = isAwaitingReply({ last_message: last_thread_message, face: bot?.face });
const reply_progress = useReplyProgress({
bot_id: bot?.id || 0,
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import isAwaitingReply from "./is-awaiting-reply";
describe("isAwaitingReply", () => {
test("the user having the last word owes a reply", () => {
expect(isAwaitingReply({ last_message: { author: "user" }, face: "idle" })).toBe(true);
});
test("an acknowledgement sent mid-reply keeps it due while the bot works", () => {
expect(isAwaitingReply({ last_message: { author: "bot", interim: 1 }, face: "working" })).toBe(
true,
);
});
test("once the reply is over, an acknowledgement left last owes nothing", () => {
expect(isAwaitingReply({ last_message: { author: "bot", interim: 1 }, face: "idle" })).toBe(false);
});
test("an answer owes nothing, even while the bot works elsewhere", () => {
expect(isAwaitingReply({ last_message: { author: "bot", interim: 0 }, face: "working" })).toBe(
false,
);
expect(isAwaitingReply({ last_message: { author: "bot" }, face: "working" })).toBe(false);
});
test("an empty thread owes nothing", () => {
expect(isAwaitingReply({ last_message: null, face: "working" })).toBe(false);
});
});
@@ -0,0 +1,25 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
type Params = {
/**
* The thread's last message.
*/
last_message?: Pick<BUN_SQLITE_COBBLE_BOT_MESSAGES, "author" | "interim"> | null;
/**
* The bot's face for this user.
*/
face?: string | null;
};
/**
* Whether a reply is due in the thread: the user has the last word, or the
* bot's last word was sent mid-reply (`interim`, an acknowledgement while
* it works) and it is still working. Once the reply ends its face moves
* on, so a reply that had nothing more to say never leaves the row up.
*/
export default function isAwaitingReply({ last_message, face }: Params) {
if (!last_message) return false;
if (last_message.author == "user") return true;
return Boolean(last_message.interim) && face == "working";
}