From 46c84b8b2c8981d10ac0d064e23021fe0c462e2a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 18:47:14 +0000 Subject: [PATCH 1/2] fix(dashboard): generate conversation titles for channel threads Previously title generation was gated on isDmChannel() because setAssistantTitle is a Slack DM-only API. This also prevented dashboard conversationTitle from being stored for any non-DM conversation, so the dashboard always fell back to showing the channel name as the title. Decouple the two responsibilities: - Generate and persist a conversation title for ALL Slack conversations (channel, private channel, group DM, DM). - Only call Slack setAssistantTitle for DM assistant threads where the API is supported. Also split error handling so a Slack permission failure on setAssistantTitle no longer suppresses the generated title for dashboard reporting purposes. Fixes: dashboard conversation list showing #channel-name as title for all non-DM conversations. Co-authored-by: David Cramer --- .../src/chat/slack/assistant-thread/title.ts | 118 +++++++++++------- 1 file changed, 75 insertions(+), 43 deletions(-) diff --git a/packages/junior/src/chat/slack/assistant-thread/title.ts b/packages/junior/src/chat/slack/assistant-thread/title.ts index 9e759ac54..092471f34 100644 --- a/packages/junior/src/chat/slack/assistant-thread/title.ts +++ b/packages/junior/src/chat/slack/assistant-thread/title.ts @@ -13,12 +13,17 @@ import type { ThreadArtifactsState } from "@/chat/state/artifacts"; import type { ThreadConversationState } from "@/chat/state/conversation"; /** - * Best-effort assistant-thread title update for DM assistant threads. + * Best-effort conversation title generation for all Slack conversations. * * Title generation is intentionally detached from reply generation and visible - * reply delivery. Stable Slack permission failures are treated as a terminal - * skip for the current source message so later turns do not keep paying for - * the same fast-model title generation call. + * reply delivery. For DM assistant threads the generated title is also pushed + * to Slack via `setAssistantTitle`. For channel conversations the title is + * generated and returned for dashboard reporting only — the Slack API for + * setting thread titles is DM-only and is not called. + * + * Stable Slack permission failures on DM title updates are treated as a + * terminal skip for the current source message so later turns do not keep + * paying for the same fast-model call that Slack will reject. */ export function maybeUpdateAssistantTitle(args: { assistantThreadContext?: { @@ -39,8 +44,7 @@ export function maybeUpdateAssistantTitle(args: { const assistantThreadContext = args.assistantThreadContext; if ( !assistantThreadContext?.channelId || - !assistantThreadContext.threadTs || - !isDmChannel(assistantThreadContext.channelId) + !assistantThreadContext.threadTs ) { return Promise.resolve(undefined); } @@ -53,46 +57,13 @@ export function maybeUpdateAssistantTitle(args: { return Promise.resolve(undefined); } + const isDm = isDmChannel(assistantThreadContext.channelId); + return (async () => { + let title: string | undefined; try { - const title = await args.generateThreadTitle(titleSourceMessage.text); - await args - .getSlackAdapter() - .setAssistantTitle( - assistantThreadContext.channelId, - assistantThreadContext.threadTs, - title, - ); - return { sourceMessageId: titleSourceMessage.id, title }; + title = await args.generateThreadTitle(titleSourceMessage.text); } catch (error) { - const slackErrorCode = getSlackApiErrorCode(error); - const assistantTitleErrorAttributes = { - "app.slack.assistant_title.outcome": "permission_denied", - ...(slackErrorCode - ? { - "app.slack.assistant_title.error_code": slackErrorCode, - } - : {}), - }; - if (isSlackTitlePermissionError(error)) { - // Persist the source message anyway so later turns do not keep paying - // for another fast-model title generation call Slack will reject. - setSpanAttributes(assistantTitleErrorAttributes); - logError( - "thread_title_generation_permission_denied", - { - slackThreadId: args.threadId, - slackUserId: args.requesterId, - slackChannelId: args.channelId, - runId: args.runId, - assistantUserName: args.assistantUserName, - modelId: args.modelId, - }, - assistantTitleErrorAttributes, - "Skipping thread title update due to Slack permission error", - ); - return { sourceMessageId: titleSourceMessage.id }; - } logWarn( "thread_title_generation_failed", { @@ -111,5 +82,66 @@ export function maybeUpdateAssistantTitle(args: { ); return undefined; } + + // Only DM assistant threads support the Slack setAssistantTitle API. + if (isDm) { + try { + await args + .getSlackAdapter() + .setAssistantTitle( + assistantThreadContext.channelId, + assistantThreadContext.threadTs, + title, + ); + } catch (error) { + const slackErrorCode = getSlackApiErrorCode(error); + const assistantTitleErrorAttributes = { + "app.slack.assistant_title.outcome": "permission_denied", + ...(slackErrorCode + ? { + "app.slack.assistant_title.error_code": slackErrorCode, + } + : {}), + }; + if (isSlackTitlePermissionError(error)) { + // Persist the source message id so later turns do not keep paying + // for another fast-model call that Slack will reject. The generated + // title is still returned for dashboard reporting. + setSpanAttributes(assistantTitleErrorAttributes); + logError( + "thread_title_generation_permission_denied", + { + slackThreadId: args.threadId, + slackUserId: args.requesterId, + slackChannelId: args.channelId, + runId: args.runId, + assistantUserName: args.assistantUserName, + modelId: args.modelId, + }, + assistantTitleErrorAttributes, + "Skipping Slack thread title update due to permission error", + ); + } else { + logWarn( + "thread_title_slack_update_failed", + { + slackThreadId: args.threadId, + slackUserId: args.requesterId, + slackChannelId: args.channelId, + runId: args.runId, + assistantUserName: args.assistantUserName, + modelId: args.modelId, + }, + { + "exception.message": + error instanceof Error ? error.message : String(error), + }, + "Slack thread title update failed", + ); + } + } + } + + return { sourceMessageId: titleSourceMessage.id, title }; })(); } From 8ef1718737e82dfddb937719dc937adc2e674015 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 18:54:43 +0000 Subject: [PATCH 2/2] test(title): add unit tests for maybeUpdateAssistantTitle Covers the key behaviors introduced in the channel-title fix: - Channel threads generate a title but never call setAssistantTitle - DM threads generate a title and call setAssistantTitle - DM Slack permission errors return the generated title (not undefined) - DM non-permission Slack errors also return the generated title - Missing context, empty conversation, dedup, and generation failures all return undefined without a model call Co-authored-by: David Cramer --- .../unit/slack/assistant-thread-title.test.ts | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 packages/junior/tests/unit/slack/assistant-thread-title.test.ts diff --git a/packages/junior/tests/unit/slack/assistant-thread-title.test.ts b/packages/junior/tests/unit/slack/assistant-thread-title.test.ts new file mode 100644 index 000000000..c6a7a6134 --- /dev/null +++ b/packages/junior/tests/unit/slack/assistant-thread-title.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ThreadArtifactsState } from "@/chat/state/artifacts"; +import type { ThreadConversationState } from "@/chat/state/conversation"; +import { maybeUpdateAssistantTitle } from "@/chat/slack/assistant-thread/title"; + +const DM_CHANNEL_ID = "D12345"; +const CHANNEL_ID = "C12345"; +const PRIVATE_CHANNEL_ID = "G12345"; +const THREAD_TS = "1700000000.000001"; +const USER_MESSAGE_ID = "msg_001"; +const USER_MESSAGE_TEXT = "How do I debug memory leaks in Node?"; +const GENERATED_TITLE = "Debugging Node.js Memory Leaks"; + +function makeConversation( + override?: Partial, +): ThreadConversationState { + return { + backfill: {}, + compactions: [], + messages: [ + { + id: USER_MESSAGE_ID, + role: "user", + text: USER_MESSAGE_TEXT, + createdAtMs: 1700000000000, + }, + ], + piMessages: [], + processing: {}, + schemaVersion: 1, + stats: { + compactedMessageCount: 0, + estimatedContextTokens: 0, + totalMessageCount: 1, + updatedAtMs: 1700000000000, + }, + vision: { byFileId: {} }, + ...override, + }; +} + +function makeArtifacts( + override?: Partial, +): ThreadArtifactsState { + return { ...override }; +} + +function makeArgs( + channelId: string, + overrides?: { + artifacts?: Partial; + generateThreadTitle?: () => Promise; + setAssistantTitle?: (...args: unknown[]) => Promise; + }, +) { + const setAssistantTitle = + overrides?.setAssistantTitle ?? vi.fn().mockResolvedValue(undefined); + const generateThreadTitle = + overrides?.generateThreadTitle ?? + vi.fn().mockResolvedValue(GENERATED_TITLE); + + return { + assistantThreadContext: { channelId, threadTs: THREAD_TS }, + assistantUserName: "junior", + artifacts: makeArtifacts(overrides?.artifacts), + channelId, + conversation: makeConversation(), + generateThreadTitle, + getSlackAdapter: () => ({ setAssistantTitle }), + modelId: "fast-model", + requesterId: "U_USER", + runId: "run_001", + threadId: `slack:${channelId}:${THREAD_TS}`, + _setAssistantTitle: setAssistantTitle, + }; +} + +describe("maybeUpdateAssistantTitle", () => { + describe("channel thread (non-DM)", () => { + it("generates and returns a title for a public channel", async () => { + const args = makeArgs(CHANNEL_ID); + const result = await maybeUpdateAssistantTitle(args); + + expect(result).toEqual({ + sourceMessageId: USER_MESSAGE_ID, + title: GENERATED_TITLE, + }); + expect(args.generateThreadTitle).toHaveBeenCalledWith(USER_MESSAGE_TEXT); + }); + + it("does NOT call setAssistantTitle for a public channel", async () => { + const args = makeArgs(CHANNEL_ID); + await maybeUpdateAssistantTitle(args); + + expect(args._setAssistantTitle).not.toHaveBeenCalled(); + }); + + it("generates and returns a title for a private channel", async () => { + const args = makeArgs(PRIVATE_CHANNEL_ID); + const result = await maybeUpdateAssistantTitle(args); + + expect(result).toEqual({ + sourceMessageId: USER_MESSAGE_ID, + title: GENERATED_TITLE, + }); + expect(args._setAssistantTitle).not.toHaveBeenCalled(); + }); + }); + + describe("DM thread", () => { + it("generates a title and calls setAssistantTitle", async () => { + const args = makeArgs(DM_CHANNEL_ID); + const result = await maybeUpdateAssistantTitle(args); + + expect(result).toEqual({ + sourceMessageId: USER_MESSAGE_ID, + title: GENERATED_TITLE, + }); + expect(args._setAssistantTitle).toHaveBeenCalledWith( + DM_CHANNEL_ID, + THREAD_TS, + GENERATED_TITLE, + ); + }); + + it("returns the generated title even when setAssistantTitle throws a permission error", async () => { + const permissionError = { data: { error: "no_permission" } }; + const args = makeArgs(DM_CHANNEL_ID, { + setAssistantTitle: vi.fn().mockRejectedValue(permissionError), + }); + + const result = await maybeUpdateAssistantTitle(args); + + expect(result).toEqual({ + sourceMessageId: USER_MESSAGE_ID, + title: GENERATED_TITLE, + }); + }); + + it("returns the generated title even when setAssistantTitle throws a non-permission error", async () => { + const args = makeArgs(DM_CHANNEL_ID, { + setAssistantTitle: vi.fn().mockRejectedValue(new Error("network fail")), + }); + + const result = await maybeUpdateAssistantTitle(args); + + expect(result).toEqual({ + sourceMessageId: USER_MESSAGE_ID, + title: GENERATED_TITLE, + }); + }); + }); + + describe("early returns", () => { + it("returns undefined when assistantThreadContext is missing", async () => { + const args = { + ...makeArgs(CHANNEL_ID), + assistantThreadContext: undefined, + }; + const result = await maybeUpdateAssistantTitle(args); + expect(result).toBeUndefined(); + }); + + it("returns undefined when there is no human message in the conversation", async () => { + const args = makeArgs(CHANNEL_ID); + args.conversation = makeConversation({ messages: [] }); + const result = await maybeUpdateAssistantTitle(args); + expect(result).toBeUndefined(); + expect(args.generateThreadTitle).not.toHaveBeenCalled(); + }); + + it("skips generation when source message id matches existing artifact (dedup)", async () => { + const args = makeArgs(CHANNEL_ID, { + artifacts: { assistantTitleSourceMessageId: USER_MESSAGE_ID }, + }); + const result = await maybeUpdateAssistantTitle(args); + expect(result).toBeUndefined(); + expect(args.generateThreadTitle).not.toHaveBeenCalled(); + }); + + it("returns undefined when title generation throws", async () => { + const args = makeArgs(CHANNEL_ID, { + generateThreadTitle: vi + .fn() + .mockRejectedValue(new Error("model error")), + }); + const result = await maybeUpdateAssistantTitle(args); + expect(result).toBeUndefined(); + }); + }); +});