Name sessions opened by handed-off work, and hide empty ones from activity

A session the coordinator's delegation, a routine or a hook opened has
no user message and no reply, so it was never named and showed as
"Untitled conversation". Tasks now name their session too, and a session
with no user message is named from the bot's own messages. Until a
session is named, Latest activity shows what it opened with, and
sessions with no messages are left out of it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-28 07:05:34 +01:00
co-authored by Claude Opus 5.5
parent b4b27dea10
commit f84f0fd0d6
9 changed files with 98 additions and 19 deletions
+4 -7
View File
@@ -7,6 +7,7 @@ import generateText from "@/src/harness/text/generate-text";
import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
import parseSessionName from "./parse-session-name";
import sessionNameTranscript from "./session-name-transcript";
import sessionReadyToName from "./session-ready-to-name";
type Params = {
session_id: number | string;
@@ -16,7 +17,8 @@ type Params = {
};
/**
* Names a session once its first exchange is in: the bot writes the title
* Names a session once its first exchange is in (or, with no user message,
* once the bot has written in it: work handed to it): the bot writes the title
* the rail and sessions list show and a short summary of it, from the
* session's first messages (however late the first reply came). The summary
* says how many messages it covers (`summary_message_count`), so
@@ -51,12 +53,7 @@ export default async function nameBotSession({
limit: AppData.BotSessionNameMessages,
});
if (
!messages.some((message) => message.author == "user") ||
!messages.some((message) => message.author == "bot" && !message.carried_from_session_id)
) {
return false;
}
if (!sessionReadyToName({ messages })) return false;
const transcript = sessionNameTranscript({ messages });
+3 -2
View File
@@ -235,9 +235,10 @@ export default async function processBotJob({
* Once the first exchange is sent, the bot names the session and
* previews it. After the reply is pushed, so it never waits on it;
* a model that can't answer now leaves the session for the next
* reply to name.
* job to name. Tasks name too: a session the coordinator's
* delegation or a routine opened has no reply to name it.
*/
if (job.kind == "reply" && !in_room && completion.message?.session_id) {
if (!in_room && completion.message?.session_id) {
/**
* The message's session, not the job's as it was claimed: a
* reply that moved the conversation on answered in the new one.
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test";
import sessionReadyToName from "./session-ready-to-name";
describe("sessionReadyToName", () => {
test("waits for the bot to answer the user", () => {
expect(sessionReadyToName({ messages: [{ id: 1, author: "user", text: "Hi" }] })).toBe(false);
});
test("names a first exchange", () => {
expect(
sessionReadyToName({
messages: [
{ id: 1, author: "user", text: "Hi" },
{ id: 2, author: "bot", text: "Hello" },
],
}),
).toBe(true);
});
test("names work handed to the bot, with no user message", () => {
expect(
sessionReadyToName({
messages: [{ id: 1, author: "bot", text: "Coordinator asked me to: **SEO lookup**" }],
}),
).toBe(true);
});
test("a carried summary alone is not the bot's answer", () => {
expect(
sessionReadyToName({
messages: [
{ id: 1, author: "bot", text: "- Crew set up", carried_from_session_id: 9 },
{ id: 2, author: "user", text: "Continue here" },
],
}),
).toBe(false);
expect(
sessionReadyToName({
messages: [{ id: 1, author: "bot", text: "- Crew set up", carried_from_session_id: 9 }],
}),
).toBe(false);
});
});
+28
View File
@@ -0,0 +1,28 @@
import type { SessionOpeningMessage } from "@/src/functions/backend/db/bot-sessions/grab-session-opening";
type Params = {
messages: SessionOpeningMessage[];
};
/**
* Whether a session's opening says enough to name it: the bot has said
* something of its own (a carried summary doesn't count), and, when the
* user wrote, after the user's first message. A session with no user
* message at all is work handed to the bot (the coordinator's delegations,
* routines, hooks), named from the bot's own messages.
*/
export default function sessionReadyToName({ messages }: Params) {
const has_bot_message = messages.some(
(message) => message.author == "bot" && !message.carried_from_session_id,
);
if (!has_bot_message) return false;
const first_user = messages.findIndex((message) => message.author == "user");
if (first_user < 0) return true;
return messages
.slice(first_user + 1)
.some((message) => message.author == "bot" && !message.carried_from_session_id);
}
+2 -2
View File
@@ -279,8 +279,8 @@ update_memory and update_instructions save to this workspace by default: what yo
export const SessionNamePrompt = `Name and summarize this conversation from its first exchange. Answer with a JSON object only, no markdown fence and no other text:
{"title": "...", "summary": "..."}
- title: what the conversation is about, at most 60 characters, no quotes and no trailing period. Say what the user wanted, not that it is a conversation. When it opens with a summary of an earlier session, name what this one carries on with, not every topic the summary recaps.
- summary: what the user wanted and what was done so far. Keep names, numbers, paths and links exactly. Plain markdown bullets, no preamble.`;
- title: what the conversation is about, at most 60 characters, no quotes and no trailing period. Say what the user wanted, not that it is a conversation. With no user message, it is work handed to you (by the coordinator, a schedule or a hook): name that work. When it opens with a summary of an earlier session, name what this one carries on with, not every topic the summary recaps.
- summary: what the user (or whoever handed you the work) wanted and what was done so far. Keep names, numbers, paths and links exactly. Plain markdown bullets, no preamble.`;
/**
* Asks the text model to carry a conversation into a new session when the
@@ -10,8 +10,8 @@ type Params = {
/**
* The user's latest conversations in a workspace, most recent first, each
* with the bot it's with (its face as the user sees it), its message count and latest message. Empty
* sessions are left out unless active.
* with the bot it's with (its face as the user sees it), its message count, first and latest message. Sessions
* with no messages yet are left out, active or not.
*/
export default async function grabWorkspaceActivity({
user_id,
@@ -28,11 +28,12 @@ export default async function grabWorkspaceActivity({
b.stone AS bot_stone,
${face.sql},
(SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count,
(SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at DESC, m.id DESC LIMIT 1) AS last_message_text
(SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at DESC, m.id DESC LIMIT 1) AS last_message_text,
(SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at ASC, m.id ASC LIMIT 1) AS first_message_text
FROM bot_sessions s
JOIN bots b ON b.id = s.bot_id
WHERE s.workspace_id = ? AND s.user_id = ? AND s.chatroom_id IS NULL
AND (s.status = 'active' OR EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id))
AND EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id)
AND (b.kind = 'coordinator' OR EXISTS (
SELECT 1 FROM workspace_bots wb WHERE wb.workspace_id = s.workspace_id AND wb.bot_id = s.bot_id
))
@@ -1,5 +1,6 @@
import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar";
import { AppData } from "@/src/data/app-data";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
@@ -13,14 +14,18 @@ type Props = {
};
/**
* One recent conversation in the workspace: the bot, the session's title,
* a preview of its latest message and when it last moved.
* One recent conversation in the workspace: the bot, the session's title
* (what it opened with until it's named), a preview of its latest message
* and when it last moved.
*/
export default function WorkspaceActivityRow({ workspace_id, item }: Props) {
const at = item.last_activity_at || item.created_at;
const preview = item.last_message_text
? messagePreview({ text: item.last_message_text })
: "";
const opening = item.first_message_text
? messagePreview({ text: item.first_message_text }).slice(0, AppData.BotSessionTitleMaxChars)
: "";
return (
<SettingsListRow
@@ -31,7 +36,7 @@ export default function WorkspaceActivityRow({ workspace_id, item }: Props) {
face={(item.bot_face || "idle") as BotFace}
/>
}
title={item.title || "Untitled conversation"}
title={item.title || opening || "Untitled conversation"}
href={`/admin/workspaces/${workspace_id}/bots/${item.bot_id}`}
badge={item.unread ? "New" : undefined}
subtitle={`${item.bot_name || "A bot"}${preview ? ` · ${preview}` : ""}`}
+4
View File
@@ -275,6 +275,10 @@ export type WorkspaceWithActivity = BUN_SQLITE_COBBLE_WORKSPACES & {
*/
export type WorkspaceActivityItem = BUN_SQLITE_COBBLE_BOT_SESSIONS & {
message_count: number;
/**
* What the session opened with, shown while it has no title yet.
*/
first_message_text?: string | null;
last_message_text?: string | null;
bot_name?: string | null;
bot_image_media_id?: number | null;