fix(slack): clear assistant thread status after replies

This commit is contained in:
Peter Steinberger
2026-01-06 21:41:30 +01:00
parent 8ebc789d25
commit 84c8209158
6 changed files with 205 additions and 179 deletions

View File

@@ -1,6 +1,7 @@
import { stripHeartbeatToken } from "../heartbeat.js"; import { stripHeartbeatToken } from "../heartbeat.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js"; import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { ReplyPayload } from "../types.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js";
import type { TypingController } from "./typing.js";
export type ReplyDispatchKind = "tool" | "block" | "final"; export type ReplyDispatchKind = "tool" | "block" | "final";
@@ -22,6 +23,20 @@ export type ReplyDispatcherOptions = {
onError?: ReplyDispatchErrorHandler; onError?: ReplyDispatchErrorHandler;
}; };
type ReplyDispatcherWithTypingOptions = Omit<
ReplyDispatcherOptions,
"onIdle"
> & {
onReplyStart?: () => Promise<void> | void;
onIdle?: () => void;
};
type ReplyDispatcherWithTypingResult = {
dispatcher: ReplyDispatcher;
replyOptions: Pick<GetReplyOptions, "onReplyStart" | "onTypingController">;
markDispatchIdle: () => void;
};
export type ReplyDispatcher = { export type ReplyDispatcher = {
sendToolResult: (payload: ReplyPayload) => boolean; sendToolResult: (payload: ReplyPayload) => boolean;
sendBlockReply: (payload: ReplyPayload) => boolean; sendBlockReply: (payload: ReplyPayload) => boolean;
@@ -107,3 +122,31 @@ export function createReplyDispatcher(
getQueuedCounts: () => ({ ...queuedCounts }), getQueuedCounts: () => ({ ...queuedCounts }),
}; };
} }
export function createReplyDispatcherWithTyping(
options: ReplyDispatcherWithTypingOptions,
): ReplyDispatcherWithTypingResult {
const { onReplyStart, onIdle, ...dispatcherOptions } = options;
let typingController: TypingController | undefined;
const dispatcher = createReplyDispatcher({
...dispatcherOptions,
onIdle: () => {
typingController?.markDispatchIdle();
onIdle?.();
},
});
return {
dispatcher,
replyOptions: {
onReplyStart,
onTypingController: (typing) => {
typingController = typing;
},
},
markDispatchIdle: () => {
typingController?.markDispatchIdle();
onIdle?.();
},
};
}

View File

@@ -33,8 +33,10 @@ import {
buildMentionRegexes, buildMentionRegexes,
matchesMentionPatterns, matchesMentionPatterns,
} from "../auto-reply/reply/mentions.js"; } from "../auto-reply/reply/mentions.js";
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; import {
import type { TypingController } from "../auto-reply/reply/typing.js"; createReplyDispatcher,
createReplyDispatcherWithTyping,
} from "../auto-reply/reply/reply-dispatcher.js";
import { getReplyFromConfig } from "../auto-reply/reply.js"; import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js"; import type { ReplyPayload } from "../auto-reply/types.js";
import type { ReplyToMode } from "../config/config.js"; import type { ReplyToMode } from "../config/config.js";
@@ -797,8 +799,8 @@ export function createDiscordMessageHandler(params: {
} }
let didSendReply = false; let didSendReply = false;
let typingController: TypingController | undefined; const { dispatcher, replyOptions, markDispatchIdle } =
const dispatcher = createReplyDispatcher({ createReplyDispatcherWithTyping({
responsePrefix: cfg.messages?.responsePrefix, responsePrefix: cfg.messages?.responsePrefix,
deliver: async (payload) => { deliver: async (payload) => {
await deliverDiscordReply({ await deliverDiscordReply({
@@ -812,28 +814,21 @@ export function createDiscordMessageHandler(params: {
}); });
didSendReply = true; didSendReply = true;
}, },
onIdle: () => {
typingController?.markDispatchIdle();
},
onError: (err, info) => { onError: (err, info) => {
runtime.error?.( runtime.error?.(
danger(`discord ${info.kind} reply failed: ${String(err)}`), danger(`discord ${info.kind} reply failed: ${String(err)}`),
); );
}, },
onReplyStart: () => sendTyping(message),
}); });
const { queuedFinal, counts } = await dispatchReplyFromConfig({ const { queuedFinal, counts } = await dispatchReplyFromConfig({
ctx: ctxPayload, ctx: ctxPayload,
cfg, cfg,
dispatcher, dispatcher,
replyOptions: { replyOptions,
onReplyStart: () => sendTyping(message),
onTypingController: (typing) => {
typingController = typing;
},
},
}); });
typingController?.markDispatchIdle(); markDispatchIdle();
if (!queuedFinal) { if (!queuedFinal) {
if ( if (
isGuildMessage && isGuildMessage &&

View File

@@ -189,12 +189,20 @@ describe("monitorSlackProvider tool results", () => {
const client = getSlackClient() as { const client = getSlackClient() as {
assistant?: { threads?: { setStatus?: ReturnType<typeof vi.fn> } }; assistant?: { threads?: { setStatus?: ReturnType<typeof vi.fn> } };
}; };
expect(client.assistant?.threads?.setStatus).toHaveBeenCalledWith({ const setStatus = client.assistant?.threads?.setStatus;
expect(setStatus).toHaveBeenCalledTimes(2);
expect(setStatus).toHaveBeenNthCalledWith(1, {
token: "bot-token", token: "bot-token",
channel_id: "C1", channel_id: "C1",
thread_ts: "123", thread_ts: "123",
status: "is typing...", status: "is typing...",
}); });
expect(setStatus).toHaveBeenNthCalledWith(2, {
token: "bot-token",
channel_id: "C1",
thread_ts: "123",
status: "",
});
}); });
it("accepts channel messages when mentionPatterns match", async () => { it("accepts channel messages when mentionPatterns match", async () => {

View File

@@ -19,8 +19,7 @@ import {
buildMentionRegexes, buildMentionRegexes,
matchesMentionPatterns, matchesMentionPatterns,
} from "../auto-reply/reply/mentions.js"; } from "../auto-reply/reply/mentions.js";
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
import type { TypingController } from "../auto-reply/reply/typing.js";
import { getReplyFromConfig } from "../auto-reply/reply.js"; import { getReplyFromConfig } from "../auto-reply/reply.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import type { ReplyPayload } from "../auto-reply/types.js"; import type { ReplyPayload } from "../auto-reply/types.js";
@@ -860,15 +859,17 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
// Only thread replies if the incoming message was in a thread. // Only thread replies if the incoming message was in a thread.
const incomingThreadTs = message.thread_ts; const incomingThreadTs = message.thread_ts;
const statusThreadTs = message.thread_ts ?? message.ts; const statusThreadTs = message.thread_ts ?? message.ts;
let didSetStatus = false;
const onReplyStart = async () => { const onReplyStart = async () => {
didSetStatus = true;
await setSlackThreadStatus({ await setSlackThreadStatus({
channelId: message.channel, channelId: message.channel,
threadTs: statusThreadTs, threadTs: statusThreadTs,
status: "is typing...", status: "is typing...",
}); });
}; };
let typingController: TypingController | undefined; const { dispatcher, replyOptions, markDispatchIdle } =
const dispatcher = createReplyDispatcher({ createReplyDispatcherWithTyping({
responsePrefix: cfg.messages?.responsePrefix, responsePrefix: cfg.messages?.responsePrefix,
deliver: async (payload) => { deliver: async (payload) => {
await deliverReplies({ await deliverReplies({
@@ -880,41 +881,36 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
threadTs: incomingThreadTs, threadTs: incomingThreadTs,
}); });
}, },
onIdle: () => {
typingController?.markDispatchIdle();
},
onError: (err, info) => { onError: (err, info) => {
runtime.error?.( runtime.error?.(
danger(`slack ${info.kind} reply failed: ${String(err)}`), danger(`slack ${info.kind} reply failed: ${String(err)}`),
); );
if (didSetStatus) {
void setSlackThreadStatus({ void setSlackThreadStatus({
channelId: message.channel, channelId: message.channel,
threadTs: statusThreadTs, threadTs: statusThreadTs,
status: "", status: "",
}); });
}
}, },
onReplyStart,
}); });
const { queuedFinal, counts } = await dispatchReplyFromConfig({ const { queuedFinal, counts } = await dispatchReplyFromConfig({
ctx: ctxPayload, ctx: ctxPayload,
cfg, cfg,
dispatcher, dispatcher,
replyOptions: { replyOptions,
onReplyStart,
onTypingController: (typing) => {
typingController = typing;
},
},
}); });
typingController?.markDispatchIdle(); markDispatchIdle();
if (!queuedFinal) { if (didSetStatus) {
await setSlackThreadStatus({ await setSlackThreadStatus({
channelId: message.channel, channelId: message.channel,
threadTs: statusThreadTs, threadTs: statusThreadTs,
status: "", status: "",
}); });
return;
} }
if (!queuedFinal) return;
if (shouldLogVerbose()) { if (shouldLogVerbose()) {
const finalCount = counts.final; const finalCount = counts.final;
logVerbose( logVerbose(

View File

@@ -19,8 +19,7 @@ import {
buildMentionRegexes, buildMentionRegexes,
matchesMentionPatterns, matchesMentionPatterns,
} from "../auto-reply/reply/mentions.js"; } from "../auto-reply/reply/mentions.js";
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
import type { TypingController } from "../auto-reply/reply/typing.js";
import type { ReplyPayload } from "../auto-reply/types.js"; import type { ReplyPayload } from "../auto-reply/types.js";
import type { ReplyToMode } from "../config/config.js"; import type { ReplyToMode } from "../config/config.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
@@ -451,8 +450,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
); );
} }
let typingController: TypingController | undefined; const { dispatcher, replyOptions, markDispatchIdle } =
const dispatcher = createReplyDispatcher({ createReplyDispatcherWithTyping({
responsePrefix: cfg.messages?.responsePrefix, responsePrefix: cfg.messages?.responsePrefix,
deliver: async (payload) => { deliver: async (payload) => {
await deliverReplies({ await deliverReplies({
@@ -465,28 +464,21 @@ export function createTelegramBot(opts: TelegramBotOptions) {
textLimit, textLimit,
}); });
}, },
onIdle: () => {
typingController?.markDispatchIdle();
},
onError: (err, info) => { onError: (err, info) => {
runtime.error?.( runtime.error?.(
danger(`telegram ${info.kind} reply failed: ${String(err)}`), danger(`telegram ${info.kind} reply failed: ${String(err)}`),
); );
}, },
onReplyStart: sendTyping,
}); });
const { queuedFinal } = await dispatchReplyFromConfig({ const { queuedFinal } = await dispatchReplyFromConfig({
ctx: ctxPayload, ctx: ctxPayload,
cfg, cfg,
dispatcher, dispatcher,
replyOptions: { replyOptions,
onReplyStart: sendTyping,
onTypingController: (typing) => {
typingController = typing;
},
},
}); });
typingController?.markDispatchIdle(); markDispatchIdle();
if (!queuedFinal) return; if (!queuedFinal) return;
}; };

View File

@@ -17,8 +17,7 @@ import {
buildMentionRegexes, buildMentionRegexes,
normalizeMentionText, normalizeMentionText,
} from "../auto-reply/reply/mentions.js"; } from "../auto-reply/reply/mentions.js";
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
import type { TypingController } from "../auto-reply/reply/typing.js";
import { getReplyFromConfig } from "../auto-reply/reply.js"; import { getReplyFromConfig } from "../auto-reply/reply.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import type { ReplyPayload } from "../auto-reply/types.js"; import type { ReplyPayload } from "../auto-reply/types.js";
@@ -1161,8 +1160,8 @@ export async function monitorWebProvider(
const textLimit = resolveTextChunkLimit(cfg, "whatsapp"); const textLimit = resolveTextChunkLimit(cfg, "whatsapp");
let didLogHeartbeatStrip = false; let didLogHeartbeatStrip = false;
let didSendReply = false; let didSendReply = false;
let typingController: TypingController | undefined; const { dispatcher, replyOptions, markDispatchIdle } =
const dispatcher = createReplyDispatcher({ createReplyDispatcherWithTyping({
responsePrefix: cfg.messages?.responsePrefix, responsePrefix: cfg.messages?.responsePrefix,
onHeartbeatStrip: () => { onHeartbeatStrip: () => {
if (!didLogHeartbeatStrip) { if (!didLogHeartbeatStrip) {
@@ -1212,9 +1211,6 @@ export async function monitorWebProvider(
} }
} }
}, },
onIdle: () => {
typingController?.markDispatchIdle();
},
onError: (err, info) => { onError: (err, info) => {
const label = const label =
info.kind === "tool" info.kind === "tool"
@@ -1226,6 +1222,7 @@ export async function monitorWebProvider(
`Failed sending web ${label} to ${msg.from ?? conversationId}: ${formatError(err)}`, `Failed sending web ${label} to ${msg.from ?? conversationId}: ${formatError(err)}`,
); );
}, },
onReplyStart: msg.sendComposing,
}); });
const { queuedFinal } = await dispatchReplyFromConfig({ const { queuedFinal } = await dispatchReplyFromConfig({
@@ -1258,14 +1255,9 @@ export async function monitorWebProvider(
cfg, cfg,
dispatcher, dispatcher,
replyResolver, replyResolver,
replyOptions: { replyOptions,
onReplyStart: msg.sendComposing,
onTypingController: (typing) => {
typingController = typing;
},
},
}); });
typingController?.markDispatchIdle(); markDispatchIdle();
if (!queuedFinal) { if (!queuedFinal) {
if (shouldClearGroupHistory && didSendReply) { if (shouldClearGroupHistory && didSendReply) {
groupHistories.set(route.sessionKey, []); groupHistories.set(route.sessionKey, []);