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:
m.shino 2026-01-30 18:33:05 +09:00
parent 3a85cb1833
commit 0548b47365
5 changed files with 114 additions and 6 deletions

View File

@ -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."

View File

@ -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)) {

View File

@ -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;

View File

@ -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 [];
}
}

View File

@ -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,