feat(slack): populate thread session with existing thread history
When a new session is created for a Slack thread, fetch and inject the full thread history as context. This preserves conversation continuity so the bot knows what it previously said in the thread. - Add resolveSlackThreadHistory() to fetch all thread messages - Add ThreadHistoryBody to context payload - Use thread history instead of just thread starter for new sessions Fixes #4470
This commit is contained in:
parent
3a85cb1833
commit
0548b47365
@ -227,10 +227,14 @@ export async function runPreparedReply(
|
||||
prefixedBodyBase,
|
||||
});
|
||||
const threadStarterBody = ctx.ThreadStarterBody?.trim();
|
||||
const threadStarterNote =
|
||||
isNewSession && threadStarterBody
|
||||
? `[Thread starter - for context]\n${threadStarterBody}`
|
||||
: undefined;
|
||||
const threadHistoryBody = ctx.ThreadHistoryBody?.trim();
|
||||
// If we have full thread history, use it instead of just the starter
|
||||
const threadContextNote =
|
||||
isNewSession && threadHistoryBody
|
||||
? `[Thread history - for context]\n${threadHistoryBody}`
|
||||
: isNewSession && threadStarterBody
|
||||
? `[Thread starter - for context]\n${threadStarterBody}`
|
||||
: undefined;
|
||||
const skillResult = await ensureSkillSnapshot({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
@ -245,7 +249,7 @@ export async function runPreparedReply(
|
||||
sessionEntry = skillResult.sessionEntry ?? sessionEntry;
|
||||
currentSystemSent = skillResult.systemSent;
|
||||
const skillsSnapshot = skillResult.skillsSnapshot;
|
||||
const prefixedBody = [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n");
|
||||
const prefixedBody = [threadContextNote, prefixedBodyBase].filter(Boolean).join("\n\n");
|
||||
const mediaNote = buildInboundMediaNote(ctx);
|
||||
const mediaReplyHint = mediaNote
|
||||
? "To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:/path or MEDIA:https://example.com/image.jpg (spaces ok, quote if needed). Keep caption in the text body."
|
||||
|
||||
@ -29,6 +29,7 @@ export function finalizeInboundContext<T extends Record<string, unknown>>(
|
||||
normalized.CommandBody = normalizeTextField(normalized.CommandBody);
|
||||
normalized.Transcript = normalizeTextField(normalized.Transcript);
|
||||
normalized.ThreadStarterBody = normalizeTextField(normalized.ThreadStarterBody);
|
||||
normalized.ThreadHistoryBody = normalizeTextField(normalized.ThreadHistoryBody);
|
||||
|
||||
const chatType = normalizeChatType(normalized.ChatType);
|
||||
if (chatType && (opts.forceChatType || normalized.ChatType !== chatType)) {
|
||||
|
||||
@ -58,6 +58,8 @@ export type MsgContext = {
|
||||
ForwardedFromSignature?: string;
|
||||
ForwardedDate?: number;
|
||||
ThreadStarterBody?: string;
|
||||
/** Full thread history when starting a new thread session. */
|
||||
ThreadHistoryBody?: string;
|
||||
ThreadLabel?: string;
|
||||
MediaPath?: string;
|
||||
MediaUrl?: string;
|
||||
|
||||
@ -122,3 +122,60 @@ export async function resolveSlackThreadStarter(params: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type SlackThreadMessage = {
|
||||
text: string;
|
||||
userId?: string;
|
||||
ts?: string;
|
||||
botId?: string;
|
||||
files?: SlackFile[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all messages in a Slack thread (excluding the current message).
|
||||
* Used to populate thread context when a new thread session starts.
|
||||
*/
|
||||
export async function resolveSlackThreadHistory(params: {
|
||||
channelId: string;
|
||||
threadTs: string;
|
||||
client: SlackWebClient;
|
||||
currentMessageTs?: string;
|
||||
limit?: number;
|
||||
}): Promise<SlackThreadMessage[]> {
|
||||
const maxMessages = params.limit ?? 20;
|
||||
try {
|
||||
const response = (await params.client.conversations.replies({
|
||||
channel: params.channelId,
|
||||
ts: params.threadTs,
|
||||
limit: maxMessages + 1, // +1 to account for filtering current message
|
||||
inclusive: true,
|
||||
})) as {
|
||||
messages?: Array<{
|
||||
text?: string;
|
||||
user?: string;
|
||||
bot_id?: string;
|
||||
ts?: string;
|
||||
files?: SlackFile[];
|
||||
}>;
|
||||
};
|
||||
|
||||
const messages = response?.messages ?? [];
|
||||
// Filter out the current message and empty messages
|
||||
return messages
|
||||
.filter((msg) => {
|
||||
if (!msg.text?.trim()) return false;
|
||||
if (params.currentMessageTs && msg.ts === params.currentMessageTs) return false;
|
||||
return true;
|
||||
})
|
||||
.slice(0, maxMessages)
|
||||
.map((msg) => ({
|
||||
text: msg.text ?? "",
|
||||
userId: msg.user,
|
||||
botId: msg.bot_id,
|
||||
ts: msg.ts,
|
||||
files: msg.files,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,7 +44,11 @@ import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-li
|
||||
import { resolveSlackEffectiveAllowFrom } from "../auth.js";
|
||||
import { resolveSlackChannelConfig } from "../channel-config.js";
|
||||
import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js";
|
||||
import { resolveSlackMedia, resolveSlackThreadStarter } from "../media.js";
|
||||
import {
|
||||
resolveSlackMedia,
|
||||
resolveSlackThreadHistory,
|
||||
resolveSlackThreadStarter,
|
||||
} from "../media.js";
|
||||
|
||||
import type { PreparedSlackMessage } from "./types.js";
|
||||
|
||||
@ -452,6 +456,7 @@ export async function prepareSlackMessage(params: {
|
||||
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
||||
|
||||
let threadStarterBody: string | undefined;
|
||||
let threadHistoryBody: string | undefined;
|
||||
let threadLabel: string | undefined;
|
||||
let threadStarterMedia: Awaited<ReturnType<typeof resolveSlackMedia>> = null;
|
||||
if (isThreadReply && threadTs) {
|
||||
@ -489,6 +494,44 @@ export async function prepareSlackMessage(params: {
|
||||
} else {
|
||||
threadLabel = `Slack thread ${roomLabel}`;
|
||||
}
|
||||
|
||||
// Fetch full thread history for new thread sessions
|
||||
// This provides context of previous messages (including bot replies) in the thread
|
||||
if (!previousTimestamp) {
|
||||
const threadHistory = await resolveSlackThreadHistory({
|
||||
channelId: message.channel,
|
||||
threadTs,
|
||||
client: ctx.app.client,
|
||||
currentMessageTs: message.ts,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
if (threadHistory.length > 0) {
|
||||
const historyParts: string[] = [];
|
||||
for (const historyMsg of threadHistory) {
|
||||
const msgUser = historyMsg.userId ? await ctx.resolveUserName(historyMsg.userId) : null;
|
||||
const msgSenderName =
|
||||
msgUser?.name ?? (historyMsg.botId ? `Bot (${historyMsg.botId})` : "Unknown");
|
||||
const isBot = Boolean(historyMsg.botId);
|
||||
const role = isBot ? "assistant" : "user";
|
||||
const msgWithId = `${historyMsg.text}\n[slack message id: ${historyMsg.ts ?? "unknown"} channel: ${message.channel}]`;
|
||||
historyParts.push(
|
||||
formatInboundEnvelope({
|
||||
channel: "Slack",
|
||||
from: `${msgSenderName} (${role})`,
|
||||
timestamp: historyMsg.ts ? Math.round(Number(historyMsg.ts) * 1000) : undefined,
|
||||
body: msgWithId,
|
||||
chatType: "channel",
|
||||
envelope: envelopeOptions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
threadHistoryBody = historyParts.join("\n\n");
|
||||
logVerbose(
|
||||
`slack: populated thread history with ${threadHistory.length} messages for new session`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use thread starter media if current message has none
|
||||
@ -516,6 +559,7 @@ export async function prepareSlackMessage(params: {
|
||||
MessageThreadId: threadContext.messageThreadId,
|
||||
ParentSessionKey: threadKeys.parentSessionKey,
|
||||
ThreadStarterBody: threadStarterBody,
|
||||
ThreadHistoryBody: threadHistoryBody,
|
||||
ThreadLabel: threadLabel,
|
||||
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
||||
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user